Add subcription feature

This commit is contained in:
Urtzi Alfaro
2026-01-13 22:22:38 +01:00
parent b931a5c45e
commit 6ddf608d37
61 changed files with 7915 additions and 1238 deletions

View File

@@ -102,6 +102,135 @@ async def register(
detail="Registration failed"
)
@router.post("/api/v1/auth/register-with-subscription", response_model=TokenResponse)
@track_execution_time("enhanced_registration_with_subscription_duration_seconds", "auth-service")
async def register_with_subscription(
user_data: UserRegistration,
request: Request,
auth_service: EnhancedAuthService = Depends(get_auth_service)
):
"""
Register new user and create subscription in one call
This endpoint implements the new registration flow where:
1. User is created
2. Payment customer is created via tenant service
3. Tenant-independent subscription is created via tenant service
4. Subscription data is stored in onboarding progress
5. User is authenticated and returned with tokens
The subscription will be linked to a tenant during the onboarding flow.
"""
metrics = get_metrics_collector(request)
logger.info("Registration with subscription attempt using new architecture",
email=user_data.email)
try:
# Enhanced input validation
if not user_data.email or not user_data.email.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is required"
)
if not user_data.password or len(user_data.password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters long"
)
if not user_data.full_name or not user_data.full_name.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Full name is required"
)
# Step 1: Register user using enhanced service
logger.info("Step 1: Creating user", email=user_data.email)
result = await auth_service.register_user(user_data)
user_id = result.user.id
logger.info("User created successfully", user_id=user_id)
# Step 2: Create subscription via tenant service (if subscription data provided)
subscription_id = None
if user_data.subscription_plan and user_data.payment_method_id:
logger.info("Step 2: Creating tenant-independent subscription",
user_id=user_id,
plan=user_data.subscription_plan)
subscription_result = await auth_service.create_subscription_via_tenant_service(
user_id=user_id,
plan_id=user_data.subscription_plan,
payment_method_id=user_data.payment_method_id,
billing_cycle=user_data.billing_cycle or "monthly",
coupon_code=user_data.coupon_code
)
if subscription_result:
subscription_id = subscription_result.get("subscription_id")
logger.info("Tenant-independent subscription created successfully",
user_id=user_id,
subscription_id=subscription_id)
# Step 3: Store subscription data in onboarding progress
logger.info("Step 3: Storing subscription data in onboarding progress",
user_id=user_id)
# Update onboarding progress with subscription data
await auth_service.save_subscription_to_onboarding_progress(
user_id=user_id,
subscription_id=subscription_id,
registration_data=user_data
)
logger.info("Subscription data stored in onboarding progress",
user_id=user_id)
else:
logger.warning("Subscription creation failed, but user registration succeeded",
user_id=user_id)
else:
logger.info("No subscription data provided, skipping subscription creation",
user_id=user_id)
# Record successful registration
if metrics:
metrics.increment_counter("enhanced_registration_with_subscription_total", labels={"status": "success"})
logger.info("Registration with subscription completed successfully using new architecture",
user_id=user_id,
email=user_data.email,
subscription_id=subscription_id)
# Add subscription_id to the response
result.subscription_id = subscription_id
return result
except HTTPException as e:
if metrics:
error_type = "validation_error" if e.status_code == 400 else "conflict" if e.status_code == 409 else "failed"
metrics.increment_counter("enhanced_registration_with_subscription_total", labels={"status": error_type})
logger.warning("Registration with subscription failed using new architecture",
email=user_data.email,
error=e.detail)
raise
except Exception as e:
if metrics:
metrics.increment_counter("enhanced_registration_with_subscription_total", labels={"status": "error"})
logger.error("Registration with subscription system error using new architecture",
email=user_data.email,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Registration with subscription failed"
)
@router.post("/api/v1/auth/login", response_model=TokenResponse)
@track_execution_time("enhanced_login_duration_seconds", "auth-service")

View File

@@ -1044,4 +1044,110 @@ async def delete_step_draft(
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete step draft"
)
@router.get("/api/v1/auth/me/onboarding/subscription-parameters", response_model=Dict[str, Any])
async def get_subscription_parameters(
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get subscription parameters saved during onboarding for tenant creation
Returns all parameters needed for subscription processing: plan, billing cycle, coupon, etc.
"""
try:
user_id = current_user["user_id"]
is_demo = current_user.get("is_demo", False)
# DEMO FIX: Demo users get default subscription parameters
if is_demo or user_id.startswith("demo-user-"):
logger.info(f"Demo user {user_id} requesting subscription parameters - returning demo defaults")
return {
"subscription_plan": "professional",
"billing_cycle": "monthly",
"coupon_code": "DEMO2025",
"payment_method_id": "pm_demo_test_123",
"payment_customer_id": "cus_demo_test_123", # Demo payment customer ID
"saved_at": datetime.now(timezone.utc).isoformat(),
"demo_mode": True
}
# Get subscription parameters from onboarding progress
from app.repositories.onboarding_repository import OnboardingRepository
onboarding_repo = OnboardingRepository(db)
subscription_params = await onboarding_repo.get_subscription_parameters(user_id)
if not subscription_params:
logger.warning(f"No subscription parameters found for user {user_id} - returning defaults")
return {
"subscription_plan": "starter",
"billing_cycle": "monthly",
"coupon_code": None,
"payment_method_id": None,
"payment_customer_id": None,
"saved_at": datetime.now(timezone.utc).isoformat()
}
logger.info(f"Retrieved subscription parameters for user {user_id}",
subscription_plan=subscription_params["subscription_plan"],
billing_cycle=subscription_params["billing_cycle"],
coupon_code=subscription_params["coupon_code"])
return subscription_params
except Exception as e:
logger.error(f"Error getting subscription parameters for user {current_user.get('user_id')}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve subscription parameters"
)
@router.get("/api/v1/auth/users/{user_id}/onboarding/subscription-parameters", response_model=Dict[str, Any])
async def get_user_subscription_parameters(
user_id: str,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get subscription parameters for a specific user (admin/service access)
"""
try:
# Check permissions - only admins and services can access other users' data
requester_id = current_user["user_id"]
requester_roles = current_user.get("roles", [])
is_service = current_user.get("is_service", False)
if not is_service and "super_admin" not in requester_roles and requester_id != user_id:
logger.warning(f"Unauthorized access attempt to user {user_id} subscription parameters by {requester_id}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions to access other users' subscription parameters"
)
# Get subscription parameters from onboarding progress
from app.repositories.onboarding_repository import OnboardingRepository
onboarding_repo = OnboardingRepository(db)
subscription_params = await onboarding_repo.get_subscription_parameters(user_id)
if not subscription_params:
logger.warning(f"No subscription parameters found for user {user_id} - returning defaults")
return {
"subscription_plan": "starter",
"billing_cycle": "monthly",
"coupon_code": None,
"payment_method_id": None,
"payment_customer_id": None,
"saved_at": datetime.now(timezone.utc).isoformat()
}
logger.info(f"Retrieved subscription parameters for user {user_id} by {requester_id}",
subscription_plan=subscription_params["subscription_plan"])
return subscription_params
except Exception as e:
logger.error(f"Error getting subscription parameters for user {user_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve subscription parameters"
)

View File

@@ -2,7 +2,7 @@
User management API routes
"""
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Path
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Path, Body
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Dict, Any
import structlog
@@ -223,7 +223,9 @@ async def get_user_by_id(
created_at=user.created_at,
last_login=user.last_login,
role=user.role,
tenant_id=None
tenant_id=None,
payment_customer_id=user.payment_customer_id,
default_payment_method_id=user.default_payment_method_id
)
except HTTPException:
@@ -481,3 +483,71 @@ async def get_user_activity(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get user activity information"
)
@router.patch("/api/v1/auth/users/{user_id}/tenant")
async def update_user_tenant(
user_id: str = Path(..., description="User ID"),
tenant_data: Dict[str, Any] = Body(..., description="Tenant data containing tenant_id"),
db: AsyncSession = Depends(get_db)
):
"""
Update user's tenant_id after tenant registration
This endpoint is called by the tenant service after a user creates their tenant.
It links the user to their newly created tenant.
"""
try:
# Log the incoming request data for debugging
logger.debug("Received tenant update request",
user_id=user_id,
tenant_data=tenant_data)
tenant_id = tenant_data.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="tenant_id is required"
)
logger.info("Updating user tenant_id",
user_id=user_id,
tenant_id=tenant_id)
user_service = UserService(db)
user = await user_service.get_user_by_id(uuid.UUID(user_id))
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Update user's tenant_id
user.tenant_id = uuid.UUID(tenant_id)
user.updated_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(user)
logger.info("Successfully updated user tenant_id",
user_id=user_id,
tenant_id=tenant_id)
return {
"success": True,
"user_id": str(user.id),
"tenant_id": str(user.tenant_id)
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to update user tenant_id",
user_id=user_id,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update user tenant_id"
)

View File

@@ -33,6 +33,10 @@ class User(Base):
timezone = Column(String(50), default="Europe/Madrid")
role = Column(String(20), nullable=False)
# Payment integration fields
payment_customer_id = Column(String(255), nullable=True, index=True)
default_payment_method_id = Column(String(255), nullable=True)
# REMOVED: All tenant relationships - these are handled by tenant service
# No tenant_memberships, tenants relationships

View File

@@ -199,9 +199,17 @@ class OnboardingRepository:
self,
user_id: str,
step_name: str,
step_data: Dict[str, Any]
step_data: Dict[str, Any],
auto_commit: bool = True
) -> UserOnboardingProgress:
"""Save data for a specific step without marking it as completed"""
"""Save data for a specific step without marking it as completed
Args:
user_id: User ID
step_name: Name of the step
step_data: Data to save
auto_commit: Whether to auto-commit (set to False when used within UnitOfWork)
"""
try:
# Get existing step or create new one
existing_step = await self.get_user_step(user_id, step_name)
@@ -221,7 +229,12 @@ class OnboardingRepository:
).returning(UserOnboardingProgress)
result = await self.db.execute(stmt)
await self.db.commit()
if auto_commit:
await self.db.commit()
else:
await self.db.flush()
return result.scalars().first()
else:
# Create new step with data but not completed
@@ -229,12 +242,14 @@ class OnboardingRepository:
user_id=user_id,
step_name=step_name,
completed=False,
step_data=step_data
step_data=step_data,
auto_commit=auto_commit
)
except Exception as e:
logger.error(f"Error saving step data for {step_name}, user {user_id}: {e}")
await self.db.rollback()
if auto_commit:
await self.db.rollback()
raise
async def get_step_data(self, user_id: str, step_name: str) -> Optional[Dict[str, Any]]:
@@ -246,6 +261,26 @@ class OnboardingRepository:
logger.error(f"Error getting step data for {step_name}, user {user_id}: {e}")
return None
async def get_subscription_parameters(self, user_id: str) -> Optional[Dict[str, Any]]:
"""Get subscription parameters saved during onboarding for tenant creation"""
try:
step_data = await self.get_step_data(user_id, "user_registered")
if step_data:
# Extract subscription-related parameters
subscription_params = {
"subscription_plan": step_data.get("subscription_plan", "starter"),
"billing_cycle": step_data.get("billing_cycle", "monthly"),
"coupon_code": step_data.get("coupon_code"),
"payment_method_id": step_data.get("payment_method_id"),
"payment_customer_id": step_data.get("payment_customer_id"),
"saved_at": step_data.get("saved_at")
}
return subscription_params
return None
except Exception as e:
logger.error(f"Error getting subscription parameters for user {user_id}: {e}")
return None
async def get_completion_stats(self) -> Dict[str, Any]:
"""Get completion statistics across all users"""
try:

View File

@@ -20,7 +20,8 @@ class UserRegistration(BaseModel):
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")
billing_cycle: Optional[str] = Field("monthly", description="Billing cycle (monthly, yearly)")
coupon_code: Optional[str] = Field(None, description="Discount coupon code")
payment_method_id: Optional[str] = Field(None, description="Stripe payment method ID")
# GDPR Consent fields
terms_accepted: Optional[bool] = Field(True, description="Accept terms of service")
@@ -76,6 +77,7 @@ class TokenResponse(BaseModel):
token_type: str = "bearer"
expires_in: int = 3600 # seconds
user: Optional[UserData] = None
subscription_id: Optional[str] = Field(None, description="Subscription ID if created during registration")
class Config:
schema_extra = {
@@ -92,7 +94,8 @@ class TokenResponse(BaseModel):
"is_verified": False,
"created_at": "2025-07-22T10:00:00Z",
"role": "user"
}
},
"subscription_id": "sub_1234567890"
}
}
@@ -110,6 +113,8 @@ class UserResponse(BaseModel):
timezone: Optional[str] = None # ✅ Added missing field
tenant_id: Optional[str] = None
role: Optional[str] = "admin"
payment_customer_id: Optional[str] = None # ✅ Added payment integration field
default_payment_method_id: Optional[str] = None # ✅ Added payment integration field
class Config:
from_attributes = True # ✅ Enable ORM mode for SQLAlchemy objects

View File

@@ -21,6 +21,7 @@ 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()
@@ -169,9 +170,62 @@ class EnhancedAuthService:
# 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.use_trial or user_data.payment_method_id:
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
@@ -181,8 +235,10 @@ class EnhancedAuthService:
plan_data = {
"subscription_plan": user_data.subscription_plan or "starter",
"subscription_tier": user_data.subscription_plan or "starter", # Store tier for enterprise onboarding logic
"use_trial": user_data.use_trial or False,
"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()
}
@@ -197,11 +253,15 @@ class EnhancedAuthService:
auto_commit=False
)
logger.info("Subscription plan saved to onboarding progress",
logger.info("Subscription plan and parameters saved to onboarding progress",
user_id=new_user.id,
plan=user_data.subscription_plan)
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 to onboarding progress",
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
@@ -730,6 +790,177 @@ class EnhancedAuthService:
)
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

View File

@@ -0,0 +1,41 @@
"""add_payment_columns_to_users
Revision ID: 20260113_add_payment_columns
Revises: 510cf1184e0b
Create Date: 2026-01-13 13:30:00.000000+00:00
Add payment_customer_id and default_payment_method_id columns to users table
to support payment integration.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '20260113_add_payment_columns'
down_revision: Union[str, None] = '510cf1184e0b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add payment_customer_id column
op.add_column('users',
sa.Column('payment_customer_id', sa.String(length=255), nullable=True))
# Add default_payment_method_id column
op.add_column('users',
sa.Column('default_payment_method_id', sa.String(length=255), nullable=True))
# Create index for payment_customer_id
op.create_index(op.f('ix_users_payment_customer_id'), 'users', ['payment_customer_id'], unique=False)
def downgrade() -> None:
# Drop index first
op.drop_index(op.f('ix_users_payment_customer_id'), table_name='users')
# Drop columns
op.drop_column('users', 'default_payment_method_id')
op.drop_column('users', 'payment_customer_id')