Add subcription feature 2

This commit is contained in:
Urtzi Alfaro
2026-01-14 13:15:48 +01:00
parent 6ddf608d37
commit a4c3b7da3f
32 changed files with 4240 additions and 965 deletions

View File

@@ -13,6 +13,20 @@ from app.schemas.auth import (
UserRegistration, UserLogin, TokenResponse, RefreshTokenRequest,
PasswordChange, PasswordReset, UserResponse
)
from pydantic import BaseModel
from typing import Optional
# Schema for SetupIntent completion data
class SetupIntentCompletionData(BaseModel):
email: str
password: str
full_name: str
setup_intent_id: str
plan_id: str
payment_method_id: str
billing_interval: str = "monthly"
coupon_code: Optional[str] = None
from app.services.auth_service import EnhancedAuthService
from app.models.users import User
from app.core.database import get_db
@@ -102,8 +116,7 @@ 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")
@router.post("/api/v1/auth/register-with-subscription")
async def register_with_subscription(
user_data: UserRegistration,
request: Request,
@@ -112,18 +125,20 @@ async def register_with_subscription(
"""
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
NEW ARCHITECTURE: User is ONLY created AFTER payment verification
Flow:
1. Validate user data
2. Create payment customer via tenant service
3. Create SetupIntent via tenant service
4. If SetupIntent requires_action: Return SetupIntent data WITHOUT creating user
5. If no SetupIntent required: Create user, create subscription, return 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",
logger.info("Registration with subscription attempt using secure architecture",
email=user_data.email)
try:
@@ -146,54 +161,135 @@ async def register_with_subscription(
detail="Full name is required"
)
# Step 1: Register user using enhanced service
logger.info("Step 1: Creating user", email=user_data.email)
# NEW ARCHITECTURE: Create payment customer and SetupIntent BEFORE user creation
if user_data.subscription_plan and user_data.payment_method_id:
logger.info("Step 1: Creating payment customer and SetupIntent BEFORE user creation",
email=user_data.email,
plan=user_data.subscription_plan)
# Use tenant service orchestration endpoint for payment setup
# This creates payment customer and SetupIntent in one coordinated workflow
payment_setup_result = await auth_service.create_registration_payment_setup_via_tenant_service(
user_data=user_data
)
if not payment_setup_result or not payment_setup_result.get('success'):
logger.error("Payment setup failed",
email=user_data.email,
error="Payment setup returned no success")
if metrics:
metrics.increment_counter("enhanced_registration_with_subscription_total",
labels={"status": "failed_payment_setup"})
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Payment setup failed"
)
# CRITICAL: Check if SetupIntent requires 3DS authentication
if payment_setup_result.get('requires_action'):
# NEW ARCHITECTURE: Return SetupIntent data WITHOUT creating user
logger.info("Payment setup requires SetupIntent authentication - deferring user creation",
email=user_data.email,
action_type=payment_setup_result.get('action_type'),
setup_intent_id=payment_setup_result.get('setup_intent_id'))
if metrics:
metrics.increment_counter("enhanced_registration_with_subscription_total",
labels={"status": "requires_3ds"})
# Return SetupIntent data for frontend to handle 3DS
# NO user created yet, NO tokens returned
return {
"requires_action": True,
"action_type": payment_setup_result.get('action_type'),
"client_secret": payment_setup_result.get('client_secret'),
"setup_intent_id": payment_setup_result.get('setup_intent_id'),
"customer_id": payment_setup_result.get('customer_id'),
"payment_customer_id": payment_setup_result.get('payment_customer_id'),
"plan_id": payment_setup_result.get('plan_id'),
"payment_method_id": payment_setup_result.get('payment_method_id'),
"trial_period_days": payment_setup_result.get('trial_period_days'),
"email": payment_setup_result.get('email'),
"full_name": payment_setup_result.get('full_name'),
"billing_interval": payment_setup_result.get('billing_interval'),
"coupon_code": payment_setup_result.get('coupon_code'),
"message": payment_setup_result.get('message') or "Payment verification required before account creation"
}
else:
# No 3DS required - proceed with user creation
logger.info("No SetupIntent required - proceeding with user creation",
email=user_data.email)
else:
# No subscription data provided - proceed with user creation
logger.info("No subscription data provided - proceeding with user creation",
email=user_data.email)
# Step 2: Create user (ONLY if no SetupIntent required)
logger.info("Step 2: Creating user after payment verification",
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)
logger.info("User created successfully",
user_id=user_id,
email=user_data.email)
# Step 2: Create subscription via tenant service (if subscription data provided)
subscription_id = None
# Step 3: If subscription was created (no 3DS), store in onboarding progress
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")
subscription_id = payment_setup_result.get("subscription_id")
if 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
# Store subscription data in onboarding progress
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)
result.subscription_id = subscription_id
else:
logger.warning("Subscription creation failed, but user registration succeeded",
logger.warning("No subscription ID returned, 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 secure architecture",
user_id=user_id,
email=user_data.email,
subscription_id=result.subscription_id)
return result
except HTTPException:
raise
except Exception as e:
if metrics:
error_type = "validation_error" if "validation" in str(e).lower() else "conflict" if "conflict" in str(e).lower() else "failed"
metrics.increment_counter("enhanced_registration_with_subscription_total",
labels={"status": error_type})
logger.error("Registration with subscription system error using secure architecture",
email=user_data.email,
error=str(e),
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Registration with subscription failed: " + str(e)
)
# Record successful registration
if metrics:
@@ -206,6 +302,30 @@ async def register_with_subscription(
# Add subscription_id to the response
result.subscription_id = subscription_id
# Check if subscription creation requires 3DS/SetupIntent authentication
if subscription_result and subscription_result.get('requires_action'):
result.requires_action = subscription_result.get('requires_action')
result.action_type = subscription_result.get('action_type')
result.client_secret = subscription_result.get('client_secret')
result.setup_intent_id = subscription_result.get('setup_intent_id')
result.payment_intent_id = subscription_result.get('payment_intent_id') # Legacy, deprecated
# Include data needed for post-3DS subscription completion
result.customer_id = subscription_result.get('customer_id')
result.plan_id = user_data.subscription_plan
result.payment_method_id = user_data.payment_method_id
result.trial_period_days = subscription_result.get('trial_period_days')
result.user_id = user_id
result.billing_interval = user_data.billing_cycle or "monthly"
result.message = subscription_result.get('message')
logger.info("Registration requires SetupIntent authentication",
user_id=user_id,
requires_action=result.requires_action,
action_type=result.action_type,
setup_intent_id=result.setup_intent_id)
return result
except HTTPException as e:
@@ -675,6 +795,142 @@ async def reset_password(
)
@router.post("/api/v1/auth/complete-registration-after-setup-intent")
@track_execution_time("registration_completion_duration_seconds", "auth-service")
async def complete_registration_after_setup_intent(
completion_data: SetupIntentCompletionData,
request: Request,
auth_service: EnhancedAuthService = Depends(get_auth_service)
):
"""
Complete user registration after SetupIntent confirmation
This endpoint is called by the frontend after 3DS authentication is complete.
It ensures users are only created after payment verification.
Args:
completion_data: Data from frontend including SetupIntent ID and user info
Returns:
TokenResponse with access_token, refresh_token, and user data
Raises:
HTTPException: 400 if SetupIntent not succeeded
HTTPException: 500 if registration fails
"""
metrics = get_metrics_collector(request)
logger.info("Completing registration after SetupIntent confirmation",
email=completion_data.email,
setup_intent_id=completion_data.setup_intent_id)
try:
# Step 1: Verify SetupIntent using tenant service orchestration
logger.info("Step 1: Verifying SetupIntent using orchestration service",
setup_intent_id=completion_data.setup_intent_id)
verification_result = await auth_service.verify_setup_intent_via_tenant_service(
completion_data.setup_intent_id
)
if not verification_result or verification_result.get('status') != 'succeeded':
status_code = status.HTTP_400_BAD_REQUEST
detail = f"SetupIntent not succeeded: {verification_result.get('status') if verification_result else 'unknown'}"
logger.warning("SetupIntent verification failed via orchestration service",
email=completion_data.email,
setup_intent_id=completion_data.setup_intent_id,
status=verification_result.get('status') if verification_result else 'unknown')
if metrics:
metrics.increment_counter("registration_completion_total", labels={"status": "failed_verification"})
raise HTTPException(status_code=status_code, detail=detail)
logger.info("SetupIntent verification succeeded via orchestration service",
setup_intent_id=completion_data.setup_intent_id)
# Step 2: Create user (ONLY after payment verification)
logger.info("Step 2: Creating user after successful payment verification",
email=completion_data.email)
user_data = UserRegistration(
email=completion_data.email,
password=completion_data.password,
full_name=completion_data.full_name,
subscription_plan=completion_data.plan_id,
payment_method_id=completion_data.payment_method_id,
billing_cycle=completion_data.billing_interval,
coupon_code=completion_data.coupon_code
)
registration_result = await auth_service.register_user(user_data)
logger.info("User created successfully after payment verification",
user_id=registration_result.user.id,
email=completion_data.email)
# Step 3: Create subscription (now that user exists)
logger.info("Step 3: Creating subscription for verified user",
user_id=registration_result.user.id,
plan_id=completion_data.plan_id)
subscription_result = await auth_service.create_subscription_via_tenant_service(
user_id=registration_result.user.id,
plan_id=completion_data.plan_id,
payment_method_id=completion_data.payment_method_id,
billing_cycle=completion_data.billing_interval,
coupon_code=completion_data.coupon_code
)
if not subscription_result or not subscription_result.get('success'):
logger.error("Subscription creation failed after successful user registration",
user_id=registration_result.user.id,
error="Subscription creation returned no success")
if metrics:
metrics.increment_counter("registration_completion_total", labels={"status": "failed_subscription"})
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Subscription creation failed after user registration"
)
logger.info("Subscription created successfully",
user_id=registration_result.user.id,
subscription_id=subscription_result.get('subscription_id'))
# Step 4: Return tokens and subscription data
registration_result.subscription_id = subscription_result.get('subscription_id')
if metrics:
metrics.increment_counter("registration_completion_total", labels={"status": "success"})
logger.info("Registration completed successfully after SetupIntent confirmation",
user_id=registration_result.user.id,
email=completion_data.email,
subscription_id=subscription_result.get('subscription_id'))
return registration_result
except HTTPException:
raise
except Exception as e:
if metrics:
metrics.increment_counter("registration_completion_total", labels={"status": "error"})
logger.error("Registration completion system error",
email=completion_data.email,
setup_intent_id=completion_data.setup_intent_id,
error=str(e),
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Registration completion failed: " + str(e)
)
@router.get("/api/v1/auth/health")
async def health_check():
"""Health check endpoint for enhanced auth service"""

View File

@@ -516,7 +516,7 @@ async def update_user_tenant(
tenant_id=tenant_id)
user_service = UserService(db)
user = await user_service.get_user_by_id(uuid.UUID(user_id))
user = await user_service.get_user_by_id(uuid.UUID(user_id), session=db)
if not user:
raise HTTPException(

View File

@@ -78,6 +78,20 @@ class TokenResponse(BaseModel):
expires_in: int = 3600 # seconds
user: Optional[UserData] = None
subscription_id: Optional[str] = Field(None, description="Subscription ID if created during registration")
# Payment action fields (3DS, SetupIntent, etc.)
requires_action: Optional[bool] = Field(None, description="Whether payment action is required (3DS, SetupIntent confirmation)")
action_type: Optional[str] = Field(None, description="Type of action required (setup_intent_confirmation, payment_intent_confirmation)")
client_secret: Optional[str] = Field(None, description="Client secret for payment confirmation")
payment_intent_id: Optional[str] = Field(None, description="Payment intent ID for 3DS authentication")
setup_intent_id: Optional[str] = Field(None, description="SetupIntent ID for payment method verification")
customer_id: Optional[str] = Field(None, description="Stripe customer ID")
# Additional fields for post-confirmation subscription completion
plan_id: Optional[str] = Field(None, description="Subscription plan ID")
payment_method_id: Optional[str] = Field(None, description="Payment method ID")
trial_period_days: Optional[int] = Field(None, description="Trial period in days")
user_id: Optional[str] = Field(None, description="User ID for post-confirmation processing")
billing_interval: Optional[str] = Field(None, description="Billing interval (monthly, yearly)")
message: Optional[str] = Field(None, description="Additional message about payment action required")
class Config:
schema_extra = {
@@ -95,7 +109,13 @@ class TokenResponse(BaseModel):
"created_at": "2025-07-22T10:00:00Z",
"role": "user"
},
"subscription_id": "sub_1234567890"
"subscription_id": "sub_1234567890",
"requires_action": True,
"action_type": "setup_intent_confirmation",
"client_secret": "seti_1234_secret_5678",
"payment_intent_id": None,
"setup_intent_id": "seti_1234567890",
"customer_id": "cus_1234567890"
}
}

View File

@@ -861,6 +861,94 @@ class EnhancedAuthService:
error=str(e))
return None
async def create_registration_payment_setup_via_tenant_service(
self,
user_data: UserRegistration
) -> Dict[str, Any]:
"""
Create registration payment setup via tenant service orchestration
This method uses the tenant service's orchestration service to create
payment customer and SetupIntent in a coordinated workflow for the
secure architecture where users are only created after payment verification.
Args:
user_data: User registration data (email, full_name, etc.)
Returns:
Dictionary with payment setup results including SetupIntent if required
"""
try:
from shared.clients.tenant_client import TenantServiceClient
from shared.config.base import BaseServiceSettings
tenant_client = TenantServiceClient(BaseServiceSettings())
# Prepare user data for tenant service orchestration
user_data_for_tenant = {
"email": user_data.email,
"full_name": user_data.full_name,
"payment_method_id": user_data.payment_method_id,
"plan_id": user_data.subscription_plan or "professional",
"billing_cycle": user_data.billing_cycle or "monthly",
"coupon_code": user_data.coupon_code
}
# Call tenant service orchestration endpoint
result = await tenant_client.create_registration_payment_setup(user_data_for_tenant)
logger.info("Registration payment setup completed via tenant service orchestration",
email=user_data.email,
requires_action=result.get('requires_action'),
setup_intent_id=result.get('setup_intent_id'))
return result
except Exception as e:
logger.error("Registration payment setup via tenant service failed",
email=user_data.email,
error=str(e),
exc_info=True)
raise
async def verify_setup_intent_via_tenant_service(
self,
setup_intent_id: str
) -> Dict[str, Any]:
"""
Verify SetupIntent via tenant service orchestration
This method uses the tenant service's orchestration service to verify
SetupIntent status before proceeding with user creation.
Args:
setup_intent_id: SetupIntent ID to verify
Returns:
Dictionary with SetupIntent verification result
"""
try:
from shared.clients.tenant_client import TenantServiceClient
from shared.config.base import BaseServiceSettings
tenant_client = TenantServiceClient(BaseServiceSettings())
# Call tenant service orchestration endpoint
result = await tenant_client.verify_setup_intent_for_registration(setup_intent_id)
logger.info("SetupIntent verified via tenant service orchestration",
setup_intent_id=setup_intent_id,
status=result.get('status'))
return result
except Exception as e:
logger.error("SetupIntent verification via tenant service failed",
setup_intent_id=setup_intent_id,
error=str(e),
exc_info=True)
raise
async def get_user_data_for_tenant_service(self, user_id: str) -> Dict[str, Any]:
"""
Get user data formatted for tenant service calls
@@ -894,6 +982,101 @@ class EnhancedAuthService:
error=str(e))
raise
async def create_payment_customer_for_registration(
self,
user_data: UserRegistration
) -> Dict[str, Any]:
"""
Create payment customer for registration (BEFORE user creation)
This method creates a payment customer in the tenant service
without requiring a user to exist first. This supports the
secure architecture where users are only created after payment verification.
Args:
user_data: User registration data
Returns:
Dictionary with payment customer creation result
Raises:
Exception: If payment customer creation fails
"""
try:
from shared.clients.tenant_client import TenantServiceClient
from app.core.config import settings
tenant_client = TenantServiceClient(settings)
# Prepare user data for tenant service (without user_id)
user_data_for_tenant = {
"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
)
logger.info("Payment customer created for registration (pre-user creation)",
email=user_data.email,
payment_customer_id=payment_result.get("payment_customer_id") if payment_result else "unknown")
return payment_result
except Exception as e:
logger.error("Payment customer creation failed for registration",
email=user_data.email,
error=str(e),
exc_info=True)
raise
async def verify_setup_intent(
self,
setup_intent_id: str
) -> Dict[str, Any]:
"""
Verify SetupIntent status with payment provider
This method checks if a SetupIntent has been successfully confirmed
(either automatically or via 3DS authentication).
Args:
setup_intent_id: SetupIntent ID to verify
Returns:
Dictionary with SetupIntent verification result
Raises:
Exception: If verification fails
"""
try:
from shared.clients.tenant_client import TenantServiceClient
from app.core.config import settings
tenant_client = TenantServiceClient(settings)
# Call tenant service to verify SetupIntent
verification_result = await tenant_client.verify_setup_intent(
setup_intent_id
)
logger.info("SetupIntent verification result",
setup_intent_id=setup_intent_id,
status=verification_result.get("status") if verification_result else "unknown")
return verification_result
except Exception as e:
logger.error("SetupIntent verification failed",
setup_intent_id=setup_intent_id,
error=str(e),
exc_info=True)
raise
async def save_subscription_to_onboarding_progress(
self,
user_id: str,

View File

@@ -5,6 +5,7 @@ Updated to use repository pattern with dependency injection and improved error h
from datetime import datetime, timezone
from typing import Dict, Any, List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import HTTPException, status
import structlog
@@ -27,22 +28,27 @@ class EnhancedUserService:
"""Initialize service with database manager"""
self.database_manager = database_manager
async def get_user_by_id(self, user_id: str) -> Optional[UserResponse]:
async def get_user_by_id(self, user_id: str, session: Optional[AsyncSession] = None) -> Optional[UserResponse]:
"""Get user by ID using repository pattern"""
try:
async with self.database_manager.get_session() as session:
if session:
# Use provided session (for direct session injection)
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,
else:
# Use database manager to get session
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,
phone=getattr(user, 'phone', None),

View File

@@ -228,8 +228,8 @@ CREATE TABLE tenants (
-- Subscription
subscription_tier VARCHAR(50) DEFAULT 'free', -- free, pro, enterprise
stripe_customer_id VARCHAR(255), -- Stripe customer ID
stripe_subscription_id VARCHAR(255), -- Stripe subscription ID
customer_id VARCHAR(255), -- Stripe customer ID
subscription_id VARCHAR(255), -- Stripe subscription ID
-- 🆕 Enterprise hierarchy fields (NEW)
parent_tenant_id UUID REFERENCES tenants(id) ON DELETE RESTRICT,
@@ -271,8 +271,8 @@ CREATE INDEX idx_tenants_hierarchy_path ON tenants(hierarchy_path);
CREATE TABLE tenant_subscriptions (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
stripe_subscription_id VARCHAR(255) UNIQUE,
stripe_customer_id VARCHAR(255),
subscription_id VARCHAR(255) UNIQUE,
customer_id VARCHAR(255),
-- Plan details
plan_tier VARCHAR(50) NOT NULL, -- free, pro, enterprise
@@ -486,7 +486,7 @@ CREATE TABLE tenant_audit_log (
```sql
CREATE INDEX idx_tenants_status ON tenants(status);
CREATE INDEX idx_tenants_subscription_tier ON tenants(subscription_tier);
CREATE INDEX idx_subscriptions_stripe ON tenant_subscriptions(stripe_subscription_id);
CREATE INDEX idx_subscriptions_stripe ON tenant_subscriptions(subscription_id);
CREATE INDEX idx_subscriptions_status ON tenant_subscriptions(tenant_id, status);
CREATE INDEX idx_members_tenant ON tenant_members(tenant_id);
CREATE INDEX idx_members_user ON tenant_members(user_id);
@@ -555,7 +555,7 @@ async def create_tenant_with_subscription(
}] if tenant.tax_id else None
)
tenant.stripe_customer_id = stripe_customer.id
tenant.customer_id = stripe_customer.id
# Attach payment method if provided
if payment_method_id:
@@ -587,13 +587,13 @@ async def create_tenant_with_subscription(
stripe_subscription = stripe.Subscription.create(**subscription_params)
tenant.stripe_subscription_id = stripe_subscription.id
tenant.subscription_id = stripe_subscription.id
# Create subscription record
subscription = TenantSubscription(
tenant_id=tenant.id,
stripe_subscription_id=stripe_subscription.id,
stripe_customer_id=stripe_customer.id,
subscription_id=stripe_subscription.id,
customer_id=stripe_customer.id,
plan_tier=plan_tier,
plan_interval='month',
plan_amount=get_plan_amount(plan_tier),
@@ -705,11 +705,11 @@ async def update_subscription(
new_price_id = get_stripe_price_id(new_plan_tier, subscription.plan_interval)
# Update Stripe subscription
stripe_subscription = stripe.Subscription.retrieve(subscription.stripe_subscription_id)
stripe_subscription = stripe.Subscription.retrieve(subscription.subscription_id)
# Update subscription items (Stripe handles proration automatically)
stripe_subscription = stripe.Subscription.modify(
subscription.stripe_subscription_id,
subscription.subscription_id,
items=[{
'id': stripe_subscription['items']['data'][0].id,
'price': new_price_id
@@ -828,7 +828,7 @@ async def handle_subscription_updated(stripe_subscription: dict):
tenant_id = UUID(stripe_subscription['metadata'].get('tenant_id'))
subscription = await db.query(TenantSubscription).filter(
TenantSubscription.stripe_subscription_id == stripe_subscription['id']
TenantSubscription.subscription_id == stripe_subscription['id']
).first()
if subscription:
@@ -846,7 +846,7 @@ async def handle_payment_failed(stripe_invoice: dict):
customer_id = stripe_invoice['customer']
tenant = await db.query(Tenant).filter(
Tenant.stripe_customer_id == customer_id
Tenant.customer_id == customer_id
).first()
if tenant:
@@ -923,9 +923,9 @@ async def upgrade_tenant_to_enterprise(
import stripe
stripe.api_key = os.getenv('STRIPE_SECRET_KEY')
stripe_subscription = stripe.Subscription.retrieve(subscription.stripe_subscription_id)
stripe_subscription = stripe.Subscription.retrieve(subscription.subscription_id)
stripe.Subscription.modify(
subscription.stripe_subscription_id,
subscription.subscription_id,
items=[{
'id': stripe_subscription['items']['data'][0].id,
'price': new_price_id
@@ -1052,8 +1052,8 @@ async def add_child_outlet_to_parent(
# 3. Create linked subscription (child shares parent subscription)
child_subscription = TenantSubscription(
tenant_id=child_tenant.id,
stripe_subscription_id=None, # Linked to parent, no separate billing
stripe_customer_id=parent.stripe_customer_id, # Same customer
subscription_id=None, # Linked to parent, no separate billing
customer_id=parent.customer_id, # Same customer
plan_tier='enterprise',
plan_interval='month',
plan_amount=Decimal('0.00'), # No additional charge

View File

@@ -200,8 +200,8 @@ async def clone_demo_data(
session_time,
"next_billing_date"
),
stripe_subscription_id=subscription_data.get('stripe_subscription_id'),
stripe_customer_id=subscription_data.get('stripe_customer_id'),
subscription_id=subscription_data.get('stripe_subscription_id'),
customer_id=subscription_data.get('stripe_customer_id'),
cancelled_at=parse_date_field(
subscription_data.get('cancelled_at'),
session_time,

View File

@@ -867,7 +867,7 @@ async def register_with_subscription(
return {
"success": True,
"message": "Registration and subscription created successfully",
"data": result
**result
}
except Exception as e:
logger.error("Failed to register with subscription", error=str(e))
@@ -924,7 +924,7 @@ async def create_subscription_endpoint(
return {
"success": True,
"message": "Subscription created successfully",
"data": result
**result
}
except Exception as e:
@@ -975,16 +975,30 @@ async def create_subscription_for_registration(
request.billing_interval,
request.coupon_code
)
# Check if result requires SetupIntent confirmation (3DS)
if result.get('requires_action'):
logger.info("Subscription creation requires SetupIntent confirmation",
user_id=request.user_data.get('user_id'),
action_type=result.get('action_type'),
setup_intent_id=result.get('setup_intent_id'))
return {
"success": True,
"message": "Payment method verification required",
**result # Spread all result fields to top level for frontend compatibility
}
# Normal subscription creation (no 3DS)
logger.info("Tenant-independent subscription created successfully",
user_id=request.user_data.get('user_id'),
subscription_id=result["subscription_id"],
subscription_id=result.get("subscription_id"),
plan_id=request.plan_id)
return {
"success": True,
"message": "Tenant-independent subscription created successfully",
"data": result
**result
}
except Exception as e:
@@ -998,6 +1012,136 @@ async def create_subscription_for_registration(
)
@router.post("/api/v1/subscriptions/complete-after-setup-intent")
async def complete_subscription_after_setup_intent(
request: dict = Body(..., description="Completion request with setup_intent_id"),
db: AsyncSession = Depends(get_db)
):
"""
Complete subscription creation after SetupIntent confirmation
This endpoint is called by the frontend after successfully confirming a SetupIntent
(with or without 3DS). It verifies the SetupIntent and creates the subscription.
Request body should contain:
- setup_intent_id: The SetupIntent ID that was confirmed
- customer_id: Stripe customer ID (from initial response)
- plan_id: Subscription plan ID
- payment_method_id: Payment method ID
- trial_period_days: Optional trial period
- user_id: User ID (for linking subscription to user)
"""
try:
setup_intent_id = request.get('setup_intent_id')
customer_id = request.get('customer_id')
plan_id = request.get('plan_id')
payment_method_id = request.get('payment_method_id')
trial_period_days = request.get('trial_period_days')
user_id = request.get('user_id')
billing_interval = request.get('billing_interval', 'monthly')
if not all([setup_intent_id, customer_id, plan_id, payment_method_id, user_id]):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing required fields: setup_intent_id, customer_id, plan_id, payment_method_id, user_id"
)
logger.info("Completing subscription after SetupIntent confirmation",
setup_intent_id=setup_intent_id,
user_id=user_id,
plan_id=plan_id)
# Use orchestration service to complete subscription
orchestration_service = SubscriptionOrchestrationService(db)
result = await orchestration_service.complete_subscription_after_setup_intent(
setup_intent_id=setup_intent_id,
customer_id=customer_id,
plan_id=plan_id,
payment_method_id=payment_method_id,
trial_period_days=trial_period_days,
user_id=user_id,
billing_interval=billing_interval
)
logger.info("Subscription completed successfully after SetupIntent",
setup_intent_id=setup_intent_id,
subscription_id=result.get('subscription_id'),
user_id=user_id)
return {
"success": True,
"message": "Subscription created successfully after SetupIntent confirmation",
**result
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to complete subscription after SetupIntent",
error=str(e),
setup_intent_id=request.get('setup_intent_id'))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to complete subscription: {str(e)}"
)
@router.get("/api/v1/subscriptions/{tenant_id}/payment-method")
async def get_payment_method(
tenant_id: str = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get the current payment method for a subscription
This endpoint retrieves the current payment method details from the payment provider
for display in the UI, including brand, last4 digits, and expiration date.
"""
try:
# Use SubscriptionOrchestrationService to get payment method
orchestration_service = SubscriptionOrchestrationService(db)
payment_method = await orchestration_service.get_payment_method(tenant_id)
if not payment_method:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No payment method found for this subscription"
)
logger.info("payment_method_retrieved_via_api",
tenant_id=tenant_id,
user_id=current_user.get("user_id"))
return payment_method
except HTTPException:
raise
except ValidationError as ve:
logger.error("get_payment_method_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(ve)
)
except DatabaseError as de:
logger.error("get_payment_method_failed",
error=str(de), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve payment method"
)
except Exception as e:
logger.error("get_payment_method_unexpected_error",
error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred while retrieving payment method"
)
@router.post("/api/v1/subscriptions/{tenant_id}/update-payment-method")
async def update_payment_method(
tenant_id: str = Path(..., description="Tenant ID"),
@@ -1007,60 +1151,43 @@ async def update_payment_method(
):
"""
Update the default payment method for a subscription
This endpoint allows users to change their payment method through the UI.
It updates the default payment method in Stripe and returns the updated
payment method information.
It updates the default payment method with the payment provider and returns
the updated payment method information.
"""
try:
# Use SubscriptionService to get subscription and update payment method
subscription_service = SubscriptionService(db)
# Get current subscription
subscription = await subscription_service.get_subscription_by_tenant_id(tenant_id)
if not subscription:
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
if not subscription.stripe_customer_id:
raise ValidationError(f"Tenant {tenant_id} does not have a Stripe customer ID")
# Update payment method via PaymentService
payment_result = await subscription_service.payment_service.update_payment_method(
subscription.stripe_customer_id,
# Use SubscriptionOrchestrationService to update payment method
orchestration_service = SubscriptionOrchestrationService(db)
result = await orchestration_service.update_payment_method(
tenant_id,
payment_method_id
)
logger.info("Payment method updated successfully",
tenant_id=tenant_id,
payment_method_id=payment_method_id,
user_id=current_user.get("user_id"))
return {
"success": True,
"message": "Payment method updated successfully",
"payment_method_id": payment_result.id,
"brand": getattr(payment_result, 'brand', 'unknown'),
"last4": getattr(payment_result, 'last4', '0000'),
"exp_month": getattr(payment_result, 'exp_month', None),
"exp_year": getattr(payment_result, 'exp_year', None)
}
return result
except ValidationError as ve:
logger.error("update_payment_method_validation_failed",
logger.error("update_payment_method_validation_failed",
error=str(ve), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(ve)
)
except DatabaseError as de:
logger.error("update_payment_method_failed",
logger.error("update_payment_method_failed",
error=str(de), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update payment method"
)
except Exception as e:
logger.error("update_payment_method_unexpected_error",
logger.error("update_payment_method_unexpected_error",
error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -1377,3 +1504,118 @@ async def redeem_coupon(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred while redeeming coupon"
)
# NEW ENDPOINTS FOR SECURE REGISTRATION ARCHITECTURE
class PaymentCustomerCreationRequest(BaseModel):
"""Request model for payment customer creation (pre-user-creation)"""
user_data: Dict[str, Any]
payment_method_id: Optional[str] = None
@router.post("/payment-customers/create")
async def create_payment_customer_for_registration(
request: PaymentCustomerCreationRequest,
db: AsyncSession = Depends(get_db)
):
"""
Create payment customer (supports pre-user-creation flow)
This endpoint creates a payment customer without requiring a user_id,
supporting the secure architecture where users are only created after
payment verification.
Uses SubscriptionOrchestrationService for proper workflow coordination.
Args:
request: Payment customer creation request
Returns:
Dictionary with payment customer creation result
"""
try:
logger.info("Creating payment customer for registration (pre-user creation)",
email=request.user_data.get('email'),
payment_method_id=request.payment_method_id)
# Use orchestration service for proper workflow coordination
orchestration_service = SubscriptionOrchestrationService(db)
result = await orchestration_service.create_registration_payment_setup(
user_data=request.user_data,
plan_id=request.plan_id if hasattr(request, 'plan_id') else "professional",
payment_method_id=request.payment_method_id,
billing_interval="monthly", # Default for registration
coupon_code=request.user_data.get('coupon_code')
)
logger.info("Payment setup completed for registration",
email=request.user_data.get('email'),
requires_action=result.get('requires_action'),
setup_intent_id=result.get('setup_intent_id'))
return {
"success": True,
**result # Include all orchestration service results
}
except Exception as e:
logger.error("Failed to create payment customer for registration",
email=request.user_data.get('email'),
error=str(e),
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create payment customer: " + str(e)
)
@router.get("/setup-intents/{setup_intent_id}/verify")
async def verify_setup_intent(
setup_intent_id: str,
db: AsyncSession = Depends(get_db)
):
"""
Verify SetupIntent status with payment provider
This endpoint checks if a SetupIntent has been successfully confirmed
(either automatically or via 3DS authentication).
Uses SubscriptionOrchestrationService for proper workflow coordination.
Args:
setup_intent_id: SetupIntent ID to verify
Returns:
Dictionary with SetupIntent verification result
"""
try:
logger.info("Verifying SetupIntent status",
setup_intent_id=setup_intent_id)
# Use orchestration service for proper workflow coordination
orchestration_service = SubscriptionOrchestrationService(db)
# Verify SetupIntent using orchestration service
result = await orchestration_service.verify_setup_intent_for_registration(
setup_intent_id
)
logger.info("SetupIntent verification result",
setup_intent_id=setup_intent_id,
status=result.get('status'))
return result
except Exception as e:
logger.error("Failed to verify SetupIntent",
setup_intent_id=setup_intent_id,
error=str(e),
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to verify SetupIntent: " + str(e)
)

View File

@@ -1138,7 +1138,7 @@ async def register_with_subscription(
):
"""Process user registration with subscription creation"""
@router.post(route_builder.build_base_route("payment-customers/create", include_tenant_prefix=False))
@router.post("/api/v1/payment-customers/create")
async def create_payment_customer(
user_data: Dict[str, Any],
payment_method_id: Optional[str] = Query(None, description="Optional payment method ID"),
@@ -1146,7 +1146,7 @@ async def create_payment_customer(
):
"""
Create a payment customer in the payment provider
This endpoint is designed for service-to-service communication from auth service
during user registration. It creates a payment customer that can be used later
for subscription creation.
@@ -1241,7 +1241,7 @@ async def register_with_subscription(
return {
"success": True,
"message": "Registration and subscription created successfully",
"data": result
**result
}
except Exception as e:
logger.error("Failed to register with subscription", error=str(e))
@@ -1291,7 +1291,7 @@ async def link_subscription_to_tenant(
return {
"success": True,
"message": "Subscription linked to tenant successfully",
"data": result
**result
}
except Exception as e:

View File

@@ -171,8 +171,10 @@ class Subscription(Base):
trial_ends_at = Column(DateTime(timezone=True))
cancelled_at = Column(DateTime(timezone=True), nullable=True)
cancellation_effective_date = Column(DateTime(timezone=True), nullable=True)
stripe_subscription_id = Column(String(255), nullable=True)
stripe_customer_id = Column(String(255), nullable=True)
# Payment provider references (generic names for provider-agnostic design)
subscription_id = Column(String(255), nullable=True) # Payment provider subscription ID
customer_id = Column(String(255), nullable=True) # Payment provider customer ID
# Limits
max_users = Column(Integer, default=5)

View File

@@ -120,12 +120,12 @@ class SubscriptionRepository(TenantBaseRepository):
error=str(e))
raise DatabaseError(f"Failed to get subscription: {str(e)}")
async def get_by_stripe_id(self, stripe_subscription_id: str) -> Optional[Subscription]:
"""Get subscription by Stripe subscription ID"""
async def get_by_provider_id(self, subscription_id: str) -> Optional[Subscription]:
"""Get subscription by payment provider subscription ID"""
try:
subscriptions = await self.get_multi(
filters={
"stripe_subscription_id": stripe_subscription_id
"subscription_id": subscription_id
},
limit=1,
order_by="created_at",
@@ -133,8 +133,8 @@ class SubscriptionRepository(TenantBaseRepository):
)
return subscriptions[0] if subscriptions else None
except Exception as e:
logger.error("Failed to get subscription by Stripe ID",
stripe_subscription_id=stripe_subscription_id,
logger.error("Failed to get subscription by provider ID",
subscription_id=subscription_id,
error=str(e))
raise DatabaseError(f"Failed to get subscription: {str(e)}")
@@ -514,7 +514,7 @@ class SubscriptionRepository(TenantBaseRepository):
"""Create a subscription not linked to any tenant (for registration flow)"""
try:
# Validate required data for tenant-independent subscription
required_fields = ["user_id", "plan", "stripe_subscription_id", "stripe_customer_id"]
required_fields = ["user_id", "plan", "subscription_id", "customer_id"]
validation_result = self._validate_tenant_data(subscription_data, required_fields)
if not validation_result["is_valid"]:

View File

@@ -396,6 +396,10 @@ class TenantRepository(TenantBaseRepository):
error=str(e))
raise DatabaseError(f"Failed to get child tenants: {str(e)}")
async def get(self, record_id: Any) -> Optional[Tenant]:
"""Get tenant by ID - alias for get_by_id for compatibility"""
return await self.get_by_id(record_id)
async def get_child_tenant_count(self, parent_tenant_id: str) -> int:
"""Get count of child tenants for a parent tenant"""
try:

View File

@@ -4,8 +4,9 @@ This service handles ONLY payment provider interactions (Stripe, etc.)
NO business logic, NO database operations, NO orchestration
"""
import asyncio
import structlog
from typing import Dict, Any, Optional, List
from typing import Dict, Any, Optional, List, Callable, Type
from datetime import datetime
from app.core.config import settings
@@ -15,6 +16,51 @@ from shared.clients.stripe_client import StripeProvider
logger = structlog.get_logger()
async def retry_with_backoff(
func: Callable,
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 10.0,
exceptions: tuple = (Exception,)
):
"""
Generic retry function with exponential backoff
Args:
func: The async function to retry
max_retries: Maximum number of retry attempts
base_delay: Initial delay between retries in seconds
max_delay: Maximum delay between retries in seconds
exceptions: Tuple of exception types to retry on
"""
for attempt in range(max_retries + 1):
try:
return await func()
except exceptions as e:
if attempt == max_retries:
# Last attempt, re-raise the exception
raise e
# Calculate delay with exponential backoff and jitter
delay = min(base_delay * (2 ** attempt), max_delay)
jitter = delay * 0.1 # 10% jitter
actual_delay = delay + (jitter * (attempt % 2)) # Alternate between + and - jitter
logger.warning(
"Payment provider API call failed, retrying",
attempt=attempt + 1,
max_retries=max_retries,
delay=actual_delay,
error=str(e),
error_type=type(e).__name__
)
await asyncio.sleep(actual_delay)
# This should never be reached, but included for completeness
raise Exception("Max retries exceeded")
class PaymentService:
"""Service for handling payment provider interactions ONLY"""
@@ -37,7 +83,13 @@ class PaymentService:
}
}
return await self.payment_provider.create_customer(customer_data)
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.create_customer(customer_data),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
return result
except Exception as e:
logger.error("Failed to create customer in payment provider", error=str(e))
raise e
@@ -49,7 +101,7 @@ class PaymentService:
payment_method_id: str,
trial_period_days: Optional[int] = None,
billing_interval: str = "monthly"
) -> Subscription:
) -> Dict[str, Any]:
"""
Create a subscription in the payment provider
@@ -61,18 +113,25 @@ class PaymentService:
billing_interval: Billing interval (monthly/yearly)
Returns:
Subscription object from payment provider
Dictionary containing subscription and authentication details
"""
try:
# Map the plan ID to the actual Stripe price ID
stripe_price_id = self._get_stripe_price_id(plan_id, billing_interval)
return await self.payment_provider.create_subscription(
customer_id,
stripe_price_id,
payment_method_id,
trial_period_days
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.create_subscription(
customer_id,
stripe_price_id,
payment_method_id,
trial_period_days
),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
return result
except Exception as e:
logger.error("Failed to create subscription in payment provider",
error=str(e),
@@ -127,7 +186,7 @@ class PaymentService:
logger.error("Failed to cancel subscription in payment provider", error=str(e))
raise e
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod:
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> Dict[str, Any]:
"""
Update the payment method for a customer
@@ -136,10 +195,16 @@ class PaymentService:
payment_method_id: New payment method ID
Returns:
PaymentMethod object
Dictionary containing payment method and authentication details
"""
try:
return await self.payment_provider.update_payment_method(customer_id, payment_method_id)
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.update_payment_method(customer_id, payment_method_id),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
return result
except Exception as e:
logger.error("Failed to update payment method in payment provider", error=str(e))
raise e
@@ -155,11 +220,76 @@ class PaymentService:
Subscription object
"""
try:
return await self.payment_provider.get_subscription(subscription_id)
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.get_subscription(subscription_id),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
return result
except Exception as e:
logger.error("Failed to get subscription from payment provider", error=str(e))
raise e
async def complete_subscription_after_setup_intent(
self,
setup_intent_id: str,
customer_id: str,
plan_id: str,
payment_method_id: str,
trial_period_days: Optional[int] = None
) -> Dict[str, Any]:
"""
Complete subscription creation after SetupIntent has been confirmed
This method is called after the frontend confirms a SetupIntent (with or without 3DS).
It verifies the SetupIntent and creates the subscription with the verified payment method.
Args:
setup_intent_id: The SetupIntent ID that was confirmed
customer_id: Payment provider customer ID
plan_id: Subscription plan ID
payment_method_id: Payment method ID
trial_period_days: Optional trial period in days
Returns:
Dictionary containing subscription details
"""
try:
logger.info("Completing subscription after SetupIntent via payment service",
setup_intent_id=setup_intent_id,
customer_id=customer_id,
plan_id=plan_id)
# Map plan ID to Stripe price ID (default to monthly)
stripe_price_id = self._get_stripe_price_id(plan_id, "monthly")
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.complete_subscription_after_setup_intent(
setup_intent_id,
customer_id,
stripe_price_id,
payment_method_id,
trial_period_days
),
max_retries=3,
exceptions=(Exception,)
)
logger.info("Subscription completed successfully after SetupIntent",
setup_intent_id=setup_intent_id,
subscription_id=result['subscription'].id if 'subscription' in result else None)
return result
except Exception as e:
logger.error("Failed to complete subscription after SetupIntent in payment service",
error=str(e),
setup_intent_id=setup_intent_id,
customer_id=customer_id,
exc_info=True)
raise e
async def update_payment_subscription(
self,
subscription_id: str,
@@ -184,14 +314,20 @@ class PaymentService:
Updated Subscription object
"""
try:
return await self.payment_provider.update_subscription(
subscription_id,
new_price_id,
proration_behavior,
billing_cycle_anchor,
payment_behavior,
immediate_change
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.update_subscription(
subscription_id,
new_price_id,
proration_behavior,
billing_cycle_anchor,
payment_behavior,
immediate_change
),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
return result
except Exception as e:
logger.error("Failed to update subscription in payment provider", error=str(e))
raise e
@@ -214,11 +350,17 @@ class PaymentService:
Dictionary with proration details
"""
try:
return await self.payment_provider.calculate_proration(
subscription_id,
new_price_id,
proration_behavior
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.calculate_proration(
subscription_id,
new_price_id,
proration_behavior
),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
return result
except Exception as e:
logger.error("Failed to calculate proration", error=str(e))
raise e
@@ -241,11 +383,17 @@ class PaymentService:
Updated Subscription object
"""
try:
return await self.payment_provider.change_billing_cycle(
subscription_id,
new_billing_cycle,
proration_behavior
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.change_billing_cycle(
subscription_id,
new_billing_cycle,
proration_behavior
),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
return result
except Exception as e:
logger.error("Failed to change billing cycle", error=str(e))
raise e
@@ -264,8 +412,12 @@ class PaymentService:
List of invoice dictionaries
"""
try:
# Fetch invoices from payment provider
stripe_invoices = await self.payment_provider.get_invoices(customer_id)
# Use retry logic for transient Stripe API failures
stripe_invoices = await retry_with_backoff(
lambda: self.payment_provider.get_invoices(customer_id),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
# Transform to response format
invoices = []
@@ -328,6 +480,28 @@ class PaymentService:
logger.error("Failed to verify webhook signature", error=str(e))
raise e
async def get_customer_payment_method(self, customer_id: str) -> Optional[PaymentMethod]:
"""
Get the current payment method for a customer
Args:
customer_id: Payment provider customer ID
Returns:
PaymentMethod object or None if no payment method exists
"""
try:
# Use retry logic for transient Stripe API failures
result = await retry_with_backoff(
lambda: self.payment_provider.get_customer_payment_method(customer_id),
max_retries=3,
exceptions=(Exception,) # Catch all exceptions for payment provider calls
)
return result
except Exception as e:
logger.error("Failed to get customer payment method", error=str(e), customer_id=customer_id)
return None
async def process_registration_with_subscription(
self,
user_data: Dict[str, Any],
@@ -338,20 +512,20 @@ class PaymentService:
) -> Dict[str, Any]:
"""
Process user registration with subscription creation
This method handles the complete flow:
1. Create payment customer (if not exists)
2. Attach payment method to customer
3. Create subscription with coupon/trial
4. Return subscription details
Args:
user_data: User data including email, name, etc.
plan_id: Subscription plan ID
payment_method_id: Payment method ID from frontend
coupon_code: Optional coupon code for discounts/trials
billing_interval: Billing interval (monthly/yearly)
Returns:
Dictionary with subscription and customer details
"""
@@ -361,7 +535,7 @@ class PaymentService:
logger.info("Payment customer created for registration",
customer_id=customer.id,
email=user_data.get('email'))
# Step 2: Attach payment method to customer
if payment_method_id:
try:
@@ -375,7 +549,7 @@ class PaymentService:
error=str(e))
# Continue without attached payment method - user can add it later
payment_method = None
# Step 3: Determine trial period from coupon
trial_period_days = None
if coupon_code:
@@ -391,7 +565,7 @@ class PaymentService:
# Other coupons might provide different trial periods
# This would be configured in your coupon system
trial_period_days = 30 # Default trial for other coupons
# Step 4: Create subscription
subscription = await self.create_payment_subscription(
customer.id,
@@ -400,13 +574,13 @@ class PaymentService:
trial_period_days,
billing_interval
)
logger.info("Subscription created successfully during registration",
subscription_id=subscription.id,
customer_id=customer.id,
plan_id=plan_id,
status=subscription.status)
# Step 5: Return comprehensive result
return {
"success": True,
@@ -434,10 +608,132 @@ class PaymentService:
"coupon_applied": coupon_code is not None,
"trial_active": trial_period_days is not None and trial_period_days > 0
}
except Exception as e:
logger.error("Failed to process registration with subscription",
error=str(e),
plan_id=plan_id,
customer_email=user_data.get('email'))
raise e
async def create_payment_customer(
self,
user_data: Dict[str, Any],
payment_method_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Create payment customer (supports pre-user-creation flow)
This method creates a payment customer without requiring a user_id,
supporting the secure architecture where users are only created after
payment verification.
Args:
user_data: User data (email, full_name, etc.)
payment_method_id: Optional payment method ID
Returns:
Dictionary with payment customer creation result
"""
try:
# Create customer without user_id (for pre-user-creation flow)
customer_data = {
'email': user_data.get('email'),
'name': user_data.get('full_name'),
'metadata': {
'registration_flow': 'pre_user_creation',
'timestamp': datetime.now(timezone.utc).isoformat()
}
}
# Create customer in payment provider
customer = await retry_with_backoff(
lambda: self.payment_provider.create_customer(customer_data),
max_retries=3,
exceptions=(Exception,)
)
logger.info("Payment customer created for registration (pre-user creation)",
customer_id=customer.id,
email=user_data.get('email'))
# Optionally attach payment method if provided
payment_method = None
if payment_method_id:
try:
payment_method = await self.update_payment_method(
customer.id,
payment_method_id
)
logger.info("Payment method attached to customer (pre-user creation)",
customer_id=customer.id,
payment_method_id=payment_method.id)
except Exception as e:
logger.warning("Failed to attach payment method during pre-user creation",
customer_id=customer.id,
error=str(e))
# Continue without payment method - can be added later
return {
"success": True,
"payment_customer_id": customer.id,
"customer_id": customer.id,
"email": user_data.get('email'),
"payment_method_id": payment_method.id if payment_method else None,
"payment_method_type": payment_method.type if payment_method else None,
"payment_method_last4": payment_method.last4 if payment_method else None
}
except Exception as e:
logger.error("Failed to create payment customer for registration",
email=user_data.get('email'),
error=str(e),
exc_info=True)
raise
async def verify_setup_intent(
self,
setup_intent_id: str
) -> Dict[str, Any]:
"""
Verify SetupIntent status with payment provider
This method checks if a SetupIntent has been successfully confirmed
(either automatically or via 3DS authentication).
Args:
setup_intent_id: SetupIntent ID to verify
Returns:
Dictionary with SetupIntent verification result
"""
try:
# Retrieve SetupIntent from payment provider
setup_intent = await retry_with_backoff(
lambda: self.payment_provider.get_setup_intent(setup_intent_id),
max_retries=3,
exceptions=(Exception,)
)
logger.info("SetupIntent verification result",
setup_intent_id=setup_intent_id,
status=setup_intent.status)
return {
"success": True,
"setup_intent_id": setup_intent.id,
"status": setup_intent.status,
"customer_id": setup_intent.customer,
"payment_method_id": setup_intent.payment_method,
"created": setup_intent.created,
"last_setup_error": setup_intent.last_setup_error,
"next_action": setup_intent.next_action,
"usage": setup_intent.usage
}
except Exception as e:
logger.error("Failed to verify SetupIntent",
setup_intent_id=setup_intent_id,
error=str(e),
exc_info=True)
raise

View File

@@ -121,7 +121,7 @@ class SubscriptionOrchestrationService:
# Step 4: Create local subscription record
logger.info("Creating local subscription record",
tenant_id=tenant_id,
stripe_subscription_id=stripe_subscription.id)
subscription_id=stripe_subscription.id)
subscription_record = await self.subscription_service.create_subscription_record(
tenant_id,
@@ -141,7 +141,7 @@ class SubscriptionOrchestrationService:
tenant_id=tenant_id)
tenant_update_data = {
'stripe_customer_id': customer.id,
'customer_id': customer.id,
'subscription_status': stripe_subscription.status,
'subscription_plan': plan_id,
'subscription_tier': plan_id,
@@ -265,13 +265,13 @@ class SubscriptionOrchestrationService:
coupon_code=coupon_code,
error=error)
# Step 3: Create subscription in payment provider
# Step 3: Create subscription in payment provider (or get SetupIntent for 3DS)
logger.info("Creating subscription in payment provider",
customer_id=customer.id,
plan_id=plan_id,
trial_period_days=trial_period_days)
stripe_subscription = await self.payment_service.create_payment_subscription(
subscription_result = await self.payment_service.create_payment_subscription(
customer.id,
plan_id,
payment_method_id,
@@ -279,6 +279,35 @@ class SubscriptionOrchestrationService:
billing_interval
)
# Check if result requires 3DS authentication (SetupIntent confirmation)
if isinstance(subscription_result, dict) and subscription_result.get('requires_action'):
logger.info("Subscription creation requires SetupIntent confirmation",
customer_id=customer.id,
action_type=subscription_result.get('action_type'),
setup_intent_id=subscription_result.get('setup_intent_id'))
# Return the SetupIntent data for frontend to handle 3DS
return {
"requires_action": True,
"action_type": subscription_result.get('action_type'),
"client_secret": subscription_result.get('client_secret'),
"setup_intent_id": subscription_result.get('setup_intent_id'),
"customer_id": customer.id,
"payment_method_id": payment_method_id,
"plan_id": plan_id,
"trial_period_days": trial_period_days,
"billing_interval": billing_interval,
"message": subscription_result.get('message'),
"user_id": user_data.get('user_id')
}
# Extract subscription object from result
# Result can be either a dict with 'subscription' key or the subscription object directly
if isinstance(subscription_result, dict) and 'subscription' in subscription_result:
stripe_subscription = subscription_result['subscription']
else:
stripe_subscription = subscription_result
logger.info("Subscription created in payment provider",
subscription_id=stripe_subscription.id,
status=stripe_subscription.status)
@@ -286,7 +315,7 @@ class SubscriptionOrchestrationService:
# Step 4: Create local subscription record WITHOUT tenant_id
logger.info("Creating tenant-independent subscription record",
user_id=user_data.get('user_id'),
stripe_subscription_id=stripe_subscription.id)
subscription_id=stripe_subscription.id)
subscription_record = await self.subscription_service.create_tenant_independent_subscription_record(
stripe_subscription.id,
@@ -345,6 +374,100 @@ class SubscriptionOrchestrationService:
error=str(e), user_id=user_data.get('user_id'))
raise DatabaseError(f"Failed to create tenant-independent subscription: {str(e)}")
async def complete_subscription_after_setup_intent(
self,
setup_intent_id: str,
customer_id: str,
plan_id: str,
payment_method_id: str,
trial_period_days: Optional[int],
user_id: str,
billing_interval: str = "monthly"
) -> Dict[str, Any]:
"""
Complete subscription creation after SetupIntent has been confirmed
This method is called after the frontend successfully confirms a SetupIntent
(with or without 3DS). It creates the subscription with the verified payment method
and creates a database record.
Args:
setup_intent_id: The confirmed SetupIntent ID
customer_id: Stripe customer ID
plan_id: Subscription plan ID
payment_method_id: Verified payment method ID
trial_period_days: Optional trial period
user_id: User ID for linking
billing_interval: Billing interval
Returns:
Dictionary with subscription details
"""
try:
logger.info("Completing subscription after SetupIntent confirmation",
setup_intent_id=setup_intent_id,
user_id=user_id,
plan_id=plan_id)
# Call payment service to complete subscription creation
result = await self.payment_service.complete_subscription_after_setup_intent(
setup_intent_id,
customer_id,
plan_id,
payment_method_id,
trial_period_days
)
stripe_subscription = result['subscription']
logger.info("Subscription created in payment provider after SetupIntent",
subscription_id=stripe_subscription.id,
status=stripe_subscription.status)
# Create local subscription record WITHOUT tenant_id (tenant-independent)
subscription_record = await self.subscription_service.create_tenant_independent_subscription_record(
stripe_subscription.id,
customer_id,
plan_id,
stripe_subscription.status,
trial_period_days,
billing_interval,
user_id
)
logger.info("Tenant-independent subscription record created after SetupIntent",
subscription_id=stripe_subscription.id,
user_id=user_id)
# Convert current_period_end to ISO format
current_period_end = stripe_subscription.current_period_end
if isinstance(current_period_end, int):
current_period_end = datetime.fromtimestamp(current_period_end, tz=timezone.utc).isoformat()
elif hasattr(current_period_end, 'isoformat'):
current_period_end = current_period_end.isoformat()
else:
current_period_end = str(current_period_end)
return {
"success": True,
"customer_id": customer_id,
"subscription_id": stripe_subscription.id,
"status": stripe_subscription.status,
"plan": plan_id,
"billing_cycle": billing_interval,
"trial_period_days": trial_period_days,
"current_period_end": current_period_end,
"user_id": user_id,
"setup_intent_id": setup_intent_id
}
except Exception as e:
logger.error("Failed to complete subscription after SetupIntent",
error=str(e),
setup_intent_id=setup_intent_id,
user_id=user_id)
raise DatabaseError(f"Failed to complete subscription: {str(e)}")
async def orchestrate_subscription_cancellation(
self,
tenant_id: str,
@@ -383,7 +506,7 @@ class SubscriptionOrchestrationService:
)
logger.info("Subscription cancelled in payment provider",
stripe_subscription_id=stripe_subscription.id,
subscription_id=stripe_subscription.id,
stripe_status=stripe_subscription.status)
# Step 4: Sync status back to database
@@ -536,7 +659,7 @@ class SubscriptionOrchestrationService:
)
logger.info("Plan updated in payment provider",
stripe_subscription_id=updated_stripe_subscription.id,
subscription_id=updated_stripe_subscription.id,
new_status=updated_stripe_subscription.status)
# Step 5: Update local subscription record
@@ -622,7 +745,7 @@ class SubscriptionOrchestrationService:
)
logger.info("Billing cycle changed in payment provider",
stripe_subscription_id=updated_stripe_subscription.id,
subscription_id=updated_stripe_subscription.id,
new_billing_cycle=new_billing_cycle)
# Step 3: Get proration details (if available)
@@ -771,6 +894,26 @@ class SubscriptionOrchestrationService:
await self._handle_subscription_resumed(event_data)
result["actions_taken"].append("subscription_resumed")
elif event_type == 'payment_intent.succeeded':
await self._handle_payment_intent_succeeded(event_data)
result["actions_taken"].append("payment_intent_succeeded")
elif event_type == 'payment_intent.payment_failed':
await self._handle_payment_intent_failed(event_data)
result["actions_taken"].append("payment_intent_failed")
elif event_type == 'payment_intent.requires_action':
await self._handle_payment_intent_requires_action(event_data)
result["actions_taken"].append("payment_intent_requires_action")
elif event_type == 'setup_intent.succeeded':
await self._handle_setup_intent_succeeded(event_data)
result["actions_taken"].append("setup_intent_succeeded")
elif event_type == 'setup_intent.requires_action':
await self._handle_setup_intent_requires_action(event_data)
result["actions_taken"].append("setup_intent_requires_action")
else:
logger.info("Unhandled webhook event type", event_type=event_type)
result["processed"] = False
@@ -800,7 +943,7 @@ class SubscriptionOrchestrationService:
status=status)
# Find tenant by customer ID
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
# Update subscription status
@@ -896,7 +1039,7 @@ class SubscriptionOrchestrationService:
customer_id=customer_id)
# Find tenant and update payment status
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
@@ -924,7 +1067,7 @@ class SubscriptionOrchestrationService:
customer_id=customer_id)
# Find tenant and update payment status
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
@@ -951,7 +1094,7 @@ class SubscriptionOrchestrationService:
customer_id=customer_id,
trial_end=trial_end)
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
@@ -978,7 +1121,7 @@ class SubscriptionOrchestrationService:
customer_id=customer_id,
subscription_id=subscription_id)
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
@@ -1004,7 +1147,7 @@ class SubscriptionOrchestrationService:
subscription_id=subscription_id,
customer_id=customer_id)
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
await self.subscription_service.update_subscription_status(
@@ -1038,7 +1181,7 @@ class SubscriptionOrchestrationService:
subscription_id=subscription_id,
customer_id=customer_id)
tenant = await self.tenant_service.get_tenant_by_stripe_customer_id(customer_id)
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
await self.subscription_service.update_subscription_status(
@@ -1062,6 +1205,155 @@ class SubscriptionOrchestrationService:
tenant_id=str(tenant.id),
subscription_id=subscription_id)
async def _handle_payment_intent_succeeded(self, event_data: Dict[str, Any]):
"""Handle payment intent succeeded event (including 3DS authenticated payments)"""
payment_intent_id = event_data['id']
customer_id = event_data.get('customer')
amount = event_data.get('amount', 0) / 100.0
currency = event_data.get('currency', 'eur').upper()
logger.info("Handling payment intent succeeded event",
payment_intent_id=payment_intent_id,
customer_id=customer_id,
amount=amount)
if customer_id:
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
'payment_action_required': False,
'last_successful_payment_at': datetime.now(timezone.utc)
}
await self.tenant_service.update_tenant_subscription_info(
str(tenant.id), tenant_update_data
)
logger.info("Payment intent succeeded event handled",
tenant_id=str(tenant.id),
payment_intent_id=payment_intent_id)
async def _handle_payment_intent_failed(self, event_data: Dict[str, Any]):
"""Handle payment intent failed event (including 3DS authentication failures)"""
payment_intent_id = event_data['id']
customer_id = event_data.get('customer')
last_payment_error = event_data.get('last_payment_error', {})
error_message = last_payment_error.get('message', 'Payment failed')
logger.warning("Handling payment intent failed event",
payment_intent_id=payment_intent_id,
customer_id=customer_id,
error_message=error_message)
if customer_id:
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
'payment_action_required': False,
'last_payment_failure_at': datetime.now(timezone.utc),
'last_payment_error': error_message
}
await self.tenant_service.update_tenant_subscription_info(
str(tenant.id), tenant_update_data
)
logger.info("Payment intent failed event handled",
tenant_id=str(tenant.id),
payment_intent_id=payment_intent_id)
async def _handle_payment_intent_requires_action(self, event_data: Dict[str, Any]):
"""Handle payment intent requires action event (3DS authentication needed)"""
payment_intent_id = event_data['id']
customer_id = event_data.get('customer')
next_action = event_data.get('next_action', {})
action_type = next_action.get('type', 'unknown')
logger.info("Handling payment intent requires action event",
payment_intent_id=payment_intent_id,
customer_id=customer_id,
action_type=action_type)
if customer_id:
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
'payment_action_required': True,
'payment_action_type': action_type,
'last_payment_action_required_at': datetime.now(timezone.utc)
}
await self.tenant_service.update_tenant_subscription_info(
str(tenant.id), tenant_update_data
)
logger.info("Payment intent requires action event handled",
tenant_id=str(tenant.id),
payment_intent_id=payment_intent_id,
action_type=action_type)
async def _handle_setup_intent_succeeded(self, event_data: Dict[str, Any]):
"""Handle setup intent succeeded event (3DS authentication completed)"""
setup_intent_id = event_data['id']
customer_id = event_data.get('customer')
logger.info("Handling setup intent succeeded event (3DS authentication completed)",
setup_intent_id=setup_intent_id,
customer_id=customer_id)
if customer_id:
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
'threeds_authentication_completed': True,
'threeds_authentication_completed_at': datetime.now(timezone.utc),
'last_threeds_setup_intent_id': setup_intent_id
}
await self.tenant_service.update_tenant_subscription_info(
str(tenant.id), tenant_update_data
)
logger.info("Setup intent succeeded event handled (3DS authentication completed)",
tenant_id=str(tenant.id),
setup_intent_id=setup_intent_id)
async def _handle_setup_intent_requires_action(self, event_data: Dict[str, Any]):
"""Handle setup intent requires action event (3DS authentication needed)"""
setup_intent_id = event_data['id']
customer_id = event_data.get('customer')
next_action = event_data.get('next_action', {})
action_type = next_action.get('type', 'unknown')
logger.info("Handling setup intent requires action event (3DS authentication needed)",
setup_intent_id=setup_intent_id,
customer_id=customer_id,
action_type=action_type)
if customer_id:
tenant = await self.tenant_service.get_tenant_by_customer_id(customer_id)
if tenant:
tenant_update_data = {
'threeds_authentication_required': True,
'threeds_authentication_required_at': datetime.now(timezone.utc),
'last_threeds_setup_intent_id': setup_intent_id,
'threeds_action_type': action_type
}
await self.tenant_service.update_tenant_subscription_info(
str(tenant.id), tenant_update_data
)
logger.info("Setup intent requires action event handled (3DS authentication needed)",
tenant_id=str(tenant.id),
setup_intent_id=setup_intent_id,
action_type=action_type)
async def orchestrate_subscription_creation_with_default_payment(
self,
tenant_id: str,
@@ -1165,3 +1457,310 @@ class SubscriptionOrchestrationService:
error=str(e))
# Don't fail the subscription creation if we can't get the default payment method
return None
async def get_payment_method(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""
Get the current payment method for a tenant's subscription
This is an orchestration method that coordinates between:
1. SubscriptionService (to get subscription data)
2. PaymentService (to get payment method from provider)
Args:
tenant_id: Tenant ID
Returns:
Dictionary with payment method details or None
"""
try:
# Get subscription from database
subscription = await self.subscription_service.get_subscription_by_tenant_id(tenant_id)
if not subscription:
logger.warning("get_payment_method_no_subscription",
tenant_id=tenant_id)
return None
# Check if subscription has a customer ID
if not subscription.customer_id:
logger.warning("get_payment_method_no_customer_id",
tenant_id=tenant_id)
return None
# Get payment method from payment provider
payment_method = await self.payment_service.get_customer_payment_method(subscription.customer_id)
if not payment_method:
logger.info("get_payment_method_not_found",
tenant_id=tenant_id,
customer_id=subscription.customer_id)
return None
logger.info("payment_method_retrieved",
tenant_id=tenant_id,
payment_method_type=payment_method.type,
last4=payment_method.last4)
return {
"brand": payment_method.brand,
"last4": payment_method.last4,
"exp_month": payment_method.exp_month,
"exp_year": payment_method.exp_year
}
except Exception as e:
logger.error("get_payment_method_failed",
error=str(e),
tenant_id=tenant_id)
return None
async def update_payment_method(
self,
tenant_id: str,
payment_method_id: str
) -> Dict[str, Any]:
"""
Update the default payment method for a tenant's subscription
This is an orchestration method that coordinates between:
1. SubscriptionService (to get subscription data)
2. PaymentService (to update payment method with provider)
Args:
tenant_id: Tenant ID
payment_method_id: New payment method ID from frontend
Returns:
Dictionary with updated payment method details
Raises:
ValidationError: If subscription or customer_id not found
"""
try:
# Get subscription from database
subscription = await self.subscription_service.get_subscription_by_tenant_id(tenant_id)
if not subscription:
raise ValidationError(f"Subscription not found for tenant {tenant_id}")
if not subscription.customer_id:
raise ValidationError(f"Tenant {tenant_id} does not have a payment customer ID")
# Update payment method via payment provider
payment_result = await self.payment_service.update_payment_method(
subscription.customer_id,
payment_method_id
)
logger.info("payment_method_updated",
tenant_id=tenant_id,
payment_method_id=payment_method_id,
requires_action=payment_result.get('requires_action', False))
pm_details = payment_result.get('payment_method', {})
return {
"success": True,
"message": "Payment method updated successfully",
"payment_method_id": pm_details.get('id'),
"brand": pm_details.get('brand', 'unknown'),
"last4": pm_details.get('last4', '0000'),
"exp_month": pm_details.get('exp_month'),
"exp_year": pm_details.get('exp_year'),
"requires_action": payment_result.get('requires_action', False),
"client_secret": payment_result.get('client_secret'),
"payment_intent_status": payment_result.get('payment_intent_status')
}
except ValidationError:
raise
except Exception as e:
logger.error("update_payment_method_failed",
error=str(e),
tenant_id=tenant_id)
raise DatabaseError(f"Failed to update payment method: {str(e)}")
async def create_registration_payment_setup(
self,
user_data: Dict[str, Any],
plan_id: str,
payment_method_id: str,
billing_interval: str = "monthly",
coupon_code: Optional[str] = None
) -> Dict[str, Any]:
"""
Create payment customer and SetupIntent for registration (pre-user-creation)
This method supports the secure architecture where users are only created
after payment verification. It creates a payment customer and SetupIntent
without requiring a user_id.
Args:
user_data: User data (email, full_name, etc.) - NO user_id required
plan_id: Subscription plan ID
payment_method_id: Payment method ID from frontend
billing_interval: Billing interval (monthly/yearly)
coupon_code: Optional coupon code
Returns:
Dictionary with payment setup results including SetupIntent if required
Raises:
Exception: If payment setup fails
"""
try:
logger.info("Starting registration payment setup (pre-user-creation)",
email=user_data.get('email'),
plan_id=plan_id)
# Step 1: Create payment customer (without user_id)
logger.info("Creating payment customer for registration",
email=user_data.get('email'))
# Create customer without user_id metadata
customer_data = {
'email': user_data.get('email'),
'name': user_data.get('full_name'),
'metadata': {
'registration_flow': 'pre_user_creation',
'timestamp': datetime.now(timezone.utc).isoformat()
}
}
customer = await self.payment_service.create_customer(customer_data)
logger.info("Payment customer created for registration",
customer_id=customer.id,
email=user_data.get('email'))
# Step 2: Handle coupon logic (if provided)
trial_period_days = 0
coupon_discount = None
if coupon_code:
logger.info("Validating and redeeming coupon code for registration",
coupon_code=coupon_code,
email=user_data.get('email'))
coupon_service = CouponService(self.db_session)
success, discount_applied, error = await coupon_service.redeem_coupon(
coupon_code,
None, # No tenant_id yet
base_trial_days=0
)
if success and discount_applied:
coupon_discount = discount_applied
trial_period_days = discount_applied.get("total_trial_days", 0)
logger.info("Coupon redeemed successfully for registration",
coupon_code=coupon_code,
trial_period_days=trial_period_days)
else:
logger.warning("Failed to redeem coupon for registration, continuing without it",
coupon_code=coupon_code,
error=error)
# Step 3: Create subscription/SetupIntent
logger.info("Creating subscription/SetupIntent for registration",
customer_id=customer.id,
plan_id=plan_id,
payment_method_id=payment_method_id)
subscription_result = await self.payment_service.create_payment_subscription(
customer.id,
plan_id,
payment_method_id,
trial_period_days if trial_period_days > 0 else None,
billing_interval
)
# Check if result requires 3DS authentication (SetupIntent confirmation)
if isinstance(subscription_result, dict) and subscription_result.get('requires_action'):
logger.info("Registration payment setup requires SetupIntent confirmation",
customer_id=customer.id,
action_type=subscription_result.get('action_type'),
setup_intent_id=subscription_result.get('setup_intent_id'))
# Return the SetupIntent data for frontend to handle 3DS
return {
"requires_action": True,
"action_type": subscription_result.get('action_type'),
"client_secret": subscription_result.get('client_secret'),
"setup_intent_id": subscription_result.get('setup_intent_id'),
"customer_id": customer.id,
"payment_customer_id": customer.id,
"plan_id": plan_id,
"payment_method_id": payment_method_id,
"trial_period_days": trial_period_days,
"billing_interval": billing_interval,
"coupon_applied": coupon_code is not None,
"email": user_data.get('email'),
"full_name": user_data.get('full_name'),
"message": subscription_result.get('message') or "Payment verification required before account creation"
}
else:
# No 3DS required - subscription created successfully
logger.info("Registration payment setup completed without 3DS",
customer_id=customer.id,
subscription_id=subscription_result.get('subscription_id'))
return {
"requires_action": False,
"subscription_id": subscription_result.get('subscription_id'),
"customer_id": customer.id,
"payment_customer_id": customer.id,
"plan_id": plan_id,
"payment_method_id": payment_method_id,
"trial_period_days": trial_period_days,
"billing_interval": billing_interval,
"coupon_applied": coupon_code is not None,
"email": user_data.get('email'),
"full_name": user_data.get('full_name'),
"message": "Payment setup completed successfully"
}
except Exception as e:
logger.error("Registration payment setup failed",
email=user_data.get('email'),
error=str(e),
exc_info=True)
raise
async def verify_setup_intent_for_registration(
self,
setup_intent_id: str
) -> Dict[str, Any]:
"""
Verify SetupIntent status for registration completion
This method checks if a SetupIntent has been successfully confirmed
(either automatically or via 3DS authentication) before proceeding
with user creation.
Args:
setup_intent_id: SetupIntent ID to verify
Returns:
Dictionary with SetupIntent verification result
Raises:
Exception: If verification fails
"""
try:
logger.info("Verifying SetupIntent for registration completion",
setup_intent_id=setup_intent_id)
# Use payment service to verify SetupIntent
verification_result = await self.payment_service.verify_setup_intent(setup_intent_id)
logger.info("SetupIntent verification result for registration",
setup_intent_id=setup_intent_id,
status=verification_result.get('status'))
return verification_result
except Exception as e:
logger.error("SetupIntent verification failed for registration",
setup_intent_id=setup_intent_id,
error=str(e),
exc_info=True)
raise

View File

@@ -30,8 +30,8 @@ class SubscriptionService:
async def create_subscription_record(
self,
tenant_id: str,
stripe_subscription_id: str,
stripe_customer_id: str,
subscription_id: str,
customer_id: str,
plan: str,
status: str,
trial_period_days: Optional[int] = None,
@@ -42,8 +42,8 @@ class SubscriptionService:
Args:
tenant_id: Tenant ID
stripe_subscription_id: Stripe subscription ID
stripe_customer_id: Stripe customer ID
subscription_id: Payment provider subscription ID
customer_id: Payment provider customer ID
plan: Subscription plan
status: Subscription status
trial_period_days: Optional trial period in days
@@ -66,8 +66,8 @@ class SubscriptionService:
# Create local subscription record
subscription_data = {
'tenant_id': str(tenant_id),
'subscription_id': stripe_subscription_id, # Stripe subscription ID
'customer_id': stripe_customer_id, # Stripe customer ID
'subscription_id': subscription_id,
'customer_id': customer_id,
'plan_id': plan,
'status': status,
'created_at': datetime.now(timezone.utc),
@@ -79,7 +79,7 @@ class SubscriptionService:
logger.info("subscription_record_created",
tenant_id=tenant_id,
subscription_id=stripe_subscription_id,
subscription_id=subscription_id,
plan=plan)
return created_subscription
@@ -181,24 +181,24 @@ class SubscriptionService:
error=str(e), tenant_id=tenant_id)
return None
async def get_subscription_by_stripe_id(
async def get_subscription_by_provider_id(
self,
stripe_subscription_id: str
subscription_id: str
) -> Optional[Subscription]:
"""
Get subscription by Stripe subscription ID
Get subscription by payment provider subscription ID
Args:
stripe_subscription_id: Stripe subscription ID
subscription_id: Payment provider subscription ID
Returns:
Subscription object or None
"""
try:
return await self.subscription_repo.get_by_stripe_id(stripe_subscription_id)
return await self.subscription_repo.get_by_provider_id(subscription_id)
except Exception as e:
logger.error("get_subscription_by_stripe_id_failed",
error=str(e), stripe_subscription_id=stripe_subscription_id)
logger.error("get_subscription_by_provider_id_failed",
error=str(e), subscription_id=subscription_id)
return None
async def cancel_subscription(
@@ -587,8 +587,8 @@ class SubscriptionService:
async def create_tenant_independent_subscription_record(
self,
stripe_subscription_id: str,
stripe_customer_id: str,
subscription_id: str,
customer_id: str,
plan: str,
status: str,
trial_period_days: Optional[int] = None,
@@ -601,8 +601,8 @@ class SubscriptionService:
This subscription is not linked to any tenant and will be linked during onboarding
Args:
stripe_subscription_id: Stripe subscription ID
stripe_customer_id: Stripe customer ID
subscription_id: Payment provider subscription ID
customer_id: Payment provider customer ID
plan: Subscription plan
status: Subscription status
trial_period_days: Optional trial period in days
@@ -615,8 +615,8 @@ class SubscriptionService:
try:
# Create tenant-independent subscription record
subscription_data = {
'stripe_subscription_id': stripe_subscription_id, # Stripe subscription ID
'stripe_customer_id': stripe_customer_id, # Stripe customer ID
'subscription_id': subscription_id,
'customer_id': customer_id,
'plan': plan, # Repository expects 'plan', not 'plan_id'
'status': status,
'created_at': datetime.now(timezone.utc),
@@ -630,7 +630,7 @@ class SubscriptionService:
created_subscription = await self.subscription_repo.create_tenant_independent_subscription(subscription_data)
logger.info("tenant_independent_subscription_record_created",
subscription_id=stripe_subscription_id,
subscription_id=subscription_id,
user_id=user_id,
plan=plan)

View File

@@ -1445,7 +1445,7 @@ class EnhancedTenantService:
# Update tenant with subscription information
tenant_update = {
"stripe_customer_id": subscription.customer_id,
"customer_id": subscription.customer_id,
"subscription_status": subscription.status,
"subscription_plan": subscription.plan,
"subscription_tier": subscription.plan,

View File

@@ -204,8 +204,8 @@ def upgrade() -> None:
sa.Column('trial_ends_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('cancelled_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('cancellation_effective_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('stripe_subscription_id', sa.String(255), nullable=True),
sa.Column('stripe_customer_id', sa.String(255), nullable=True),
sa.Column('subscription_id', sa.String(255), nullable=True),
sa.Column('customer_id', sa.String(255), nullable=True),
# Basic resource limits
sa.Column('max_users', sa.Integer(), nullable=True),
sa.Column('max_locations', sa.Integer(), nullable=True),
@@ -284,24 +284,24 @@ def upgrade() -> None:
WHERE status = 'active'
""")
# Index 5: Stripe subscription lookup (for webhook processing)
if not _index_exists(connection, 'idx_subscriptions_stripe_sub_id'):
# Index 5: Subscription ID lookup (for webhook processing)
if not _index_exists(connection, 'idx_subscriptions_subscription_id'):
op.create_index(
'idx_subscriptions_stripe_sub_id',
'idx_subscriptions_subscription_id',
'subscriptions',
['stripe_subscription_id'],
['subscription_id'],
unique=False,
postgresql_where=sa.text("stripe_subscription_id IS NOT NULL")
postgresql_where=sa.text("subscription_id IS NOT NULL")
)
# Index 6: Stripe customer lookup (for customer-related operations)
if not _index_exists(connection, 'idx_subscriptions_stripe_customer_id'):
# Index 6: Customer ID lookup (for customer-related operations)
if not _index_exists(connection, 'idx_subscriptions_customer_id'):
op.create_index(
'idx_subscriptions_stripe_customer_id',
'idx_subscriptions_customer_id',
'subscriptions',
['stripe_customer_id'],
['customer_id'],
unique=False,
postgresql_where=sa.text("stripe_customer_id IS NOT NULL")
postgresql_where=sa.text("customer_id IS NOT NULL")
)
# Index 7: User ID for tenant linking
@@ -481,8 +481,8 @@ def downgrade() -> None:
# Drop subscriptions table indexes first
op.drop_index('idx_subscriptions_linking_status', table_name='subscriptions')
op.drop_index('idx_subscriptions_user_id', table_name='subscriptions')
op.drop_index('idx_subscriptions_stripe_customer_id', table_name='subscriptions')
op.drop_index('idx_subscriptions_stripe_sub_id', table_name='subscriptions')
op.drop_index('idx_subscriptions_customer_id', table_name='subscriptions')
op.drop_index('idx_subscriptions_subscription_id', table_name='subscriptions')
op.drop_index('idx_subscriptions_active_tenant', table_name='subscriptions')
op.drop_index('idx_subscriptions_status_billing', table_name='subscriptions')
op.drop_index('idx_subscriptions_tenant_covering', table_name='subscriptions')