Add subcription feature 4

This commit is contained in:
Urtzi Alfaro
2026-01-15 22:06:36 +01:00
parent b674708a4c
commit 483a9f64cd
10 changed files with 1209 additions and 1390 deletions

View File

@@ -1,6 +1,13 @@
"""
Tenant Service API Endpoints for Subscription and Registration
Updated with new atomic registration flow support
NEW ARCHITECTURE (SetupIntent-first):
1. /registration-payment-setup - Creates customer + SetupIntent only (NO subscription)
2. Frontend confirms SetupIntent (handles 3DS if needed)
3. /verify-and-complete-registration - Creates subscription AFTER verification
This eliminates duplicate subscriptions by only creating the subscription
after payment verification is complete.
"""
import logging
@@ -8,6 +15,7 @@ from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.subscription_orchestration_service import SubscriptionOrchestrationService
from app.services.coupon_service import CouponService
from app.core.database import get_db
from app.services.registration_state_service import (
registration_state_service,
@@ -18,17 +26,13 @@ from shared.exceptions.payment_exceptions import (
PaymentServiceError,
SetupIntentError,
SubscriptionCreationFailed,
ThreeDSAuthenticationRequired
)
from shared.exceptions.registration_exceptions import (
RegistrationStateError,
InvalidStateTransitionError
)
# Configure logging
logger = logging.getLogger(__name__)
# Create router
router = APIRouter(prefix="/api/v1/tenants", tags=["tenant"])
@@ -46,7 +50,7 @@ async def get_registration_state_service() -> RegistrationStateService:
@router.post("/registration-payment-setup",
response_model=Dict[str, Any],
summary="Initiate registration payment setup")
summary="Start registration payment setup")
async def create_registration_payment_setup(
user_data: Dict[str, Any],
request: Request,
@@ -54,69 +58,46 @@ async def create_registration_payment_setup(
state_service: RegistrationStateService = Depends(get_registration_state_service)
) -> Dict[str, Any]:
"""
Initiate registration payment setup with SetupIntent-first approach
This is the FIRST step in secure registration flow:
1. Creates payment customer
2. Attaches payment method
3. Creates SetupIntent for verification
4. Returns SetupIntent to frontend for 3DS handling
Start registration payment setup (SetupIntent-first architecture).
NEW ARCHITECTURE: Only creates customer + SetupIntent here.
NO subscription is created - subscription is created in verify-and-complete-registration.
Flow:
1. Create Stripe customer
2. Create SetupIntent for payment verification
3. Return SetupIntent to frontend for 3DS handling
4. Frontend confirms SetupIntent
5. (Next endpoint) Creates subscription after verification
Args:
user_data: User registration data with payment info
Returns:
Payment setup result (may require 3DS)
Raises:
HTTPException: 400 for validation errors, 500 for server errors
SetupIntent data for frontend confirmation
"""
state_id = None
try:
print(f"DEBUG_PRINT: Registration payment setup request received for {user_data.get('email')}")
logger.critical(
"Registration payment setup request received (CRITICAL)",
extra={
"email": user_data.get('email'),
"plan_id": user_data.get('plan_id')
}
)
logger.info("Registration payment setup started",
extra={"email": user_data.get('email'), "plan_id": user_data.get('plan_id')})
# Validate required fields
if not user_data.get('email'):
logger.error("Registration payment setup failed: Email missing")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is required"
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is required")
if not user_data.get('payment_method_id'):
logger.error("Registration payment setup failed: Payment method ID missing", extra={"email": user_data.get('email')})
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Payment method ID is required"
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Payment method ID is required")
if not user_data.get('plan_id'):
logger.error("Registration payment setup failed: Plan ID missing", extra={"email": user_data.get('email')})
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Plan ID is required"
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Plan ID is required")
# Create registration state
print(f"DEBUG_PRINT: Creating registration state for {user_data['email']}")
logger.critical("Creating registration state", extra={"email": user_data['email']})
state_id = await state_service.create_registration_state(
email=user_data['email'],
user_data=user_data
)
logger.critical("Registration state created", extra={"state_id": state_id, "email": user_data['email']})
# Initiate payment setup
print(f"DEBUG_PRINT: Calling orchestration service for {user_data['email']}")
logger.critical("Calling orchestration service for payment setup", extra={"state_id": state_id, "email": user_data['email']})
# Create customer + SetupIntent (NO subscription yet!)
result = await orchestration_service.create_registration_payment_setup(
user_data=user_data,
plan_id=user_data.get('plan_id', 'professional'),
@@ -124,108 +105,55 @@ async def create_registration_payment_setup(
billing_interval=user_data.get('billing_cycle', 'monthly'),
coupon_code=user_data.get('coupon_code')
)
logger.critical("Payment orchestration completed", extra={"state_id": state_id, "email": user_data['email'], "requires_action": result.get('requires_action')})
# Update state with payment setup results
# Note: setup_intent_id may not be present if no 3DS was required
# Update state with setup results
await state_service.update_state_context(state_id, {
'setup_intent_id': result.get('setup_intent_id'),
'subscription_id': result.get('subscription_id'),
'customer_id': result.get('customer_id'),
'payment_customer_id': result.get('payment_customer_id'),
'payment_method_id': result.get('payment_method_id')
'payment_method_id': result.get('payment_method_id'),
'plan_id': result.get('plan_id'),
'billing_interval': result.get('billing_interval'),
'trial_period_days': result.get('trial_period_days'),
'coupon_code': result.get('coupon_code')
})
# Transition to payment verification pending state
await state_service.transition_state(
state_id,
RegistrationState.PAYMENT_VERIFICATION_PENDING
)
logger.critical(
"Registration payment setup flow successful",
extra={
"email": user_data.get('email'),
"state_id": state_id
}
)
await state_service.transition_state(state_id, RegistrationState.PAYMENT_VERIFICATION_PENDING)
logger.info("Registration payment setup completed",
extra={
"email": user_data.get('email'),
"setup_intent_id": result.get('setup_intent_id'),
"requires_action": result.get('requires_action')
})
return {
"success": True,
"requires_action": result.get('requires_action', False),
"action_type": result.get('action_type'),
"requires_action": result.get('requires_action', True),
"action_type": result.get('action_type', 'use_stripe_sdk'),
"client_secret": result.get('client_secret'),
"setup_intent_id": result.get('setup_intent_id'),
"customer_id": result.get('customer_id'),
"payment_customer_id": result.get('payment_customer_id'),
"payment_customer_id": result.get('customer_id'),
"plan_id": result.get('plan_id'),
"payment_method_id": result.get('payment_method_id'),
"subscription_id": result.get('subscription_id'),
"billing_cycle": result.get('billing_cycle'),
"trial_period_days": result.get('trial_period_days', 0),
"billing_cycle": result.get('billing_interval'),
"email": result.get('email'),
"state_id": state_id,
"message": result.get('message') or "Payment setup completed successfully."
}
except ThreeDSAuthenticationRequired as e:
# 3DS authentication required - return SetupIntent data for frontend
logger.info(f"3DS authentication required for registration: email={user_data.get('email')}, setup_intent_id={e.setup_intent_id}", extra={"email": user_data.get('email'), "setup_intent_id": e.setup_intent_id})
# Update state with payment setup results
await state_service.update_state_context(state_id, {
'setup_intent_id': e.setup_intent_id,
'customer_id': e.extra_data.get('customer_id'),
'payment_customer_id': e.extra_data.get('customer_id'),
'payment_method_id': e.extra_data.get('payment_method_id')
})
# Transition to payment verification pending state
await state_service.transition_state(
state_id,
RegistrationState.PAYMENT_VERIFICATION_PENDING
)
return {
"success": True,
"requires_action": True,
"action_type": e.action_type,
"client_secret": e.client_secret,
"setup_intent_id": e.setup_intent_id,
"subscription_id": e.extra_data.get('subscription_id'),
"customer_id": e.extra_data.get('customer_id'),
"payment_customer_id": e.extra_data.get('customer_id'),
"plan_id": e.extra_data.get('plan_id'),
"payment_method_id": e.extra_data.get('payment_method_id'),
"billing_cycle": e.extra_data.get('billing_interval'),
"email": e.extra_data.get('email'),
"state_id": state_id,
"message": e.extra_data.get('message') or "Payment verification required. Frontend must confirm SetupIntent to handle 3DS."
"message": result.get('message', 'Payment verification required')
}
except PaymentServiceError as e:
logger.error(f"Payment service error in registration setup: {str(e)}, email: {user_data.get('email')}",
extra={"email": user_data.get('email')},
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Payment setup failed: {str(e)}"
) from e
logger.error(f"Payment setup failed: {str(e)}", extra={"email": user_data.get('email')}, exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Payment setup failed: {str(e)}") from e
except RegistrationStateError as e:
logger.error(f"Registration state error in payment setup: {str(e)}, email: {user_data.get('email')}",
extra={"email": user_data.get('email')},
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Registration state error: {str(e)}"
) from e
logger.error(f"Registration state error: {str(e)}", extra={"email": user_data.get('email')}, exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Registration state error: {str(e)}") from e
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error in registration payment setup: {str(e)}, email: {user_data.get('email')}",
extra={"email": user_data.get('email')},
exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Registration payment setup failed: {str(e)}"
) from e
logger.error(f"Unexpected error: {str(e)}", extra={"email": user_data.get('email')}, exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Registration failed: {str(e)}") from e
@router.post("/verify-and-complete-registration",
@@ -234,258 +162,153 @@ async def create_registration_payment_setup(
async def verify_and_complete_registration(
verification_data: Dict[str, Any],
request: Request,
db: AsyncSession = Depends(get_db),
orchestration_service: SubscriptionOrchestrationService = Depends(get_subscription_orchestration_service),
state_service: RegistrationStateService = Depends(get_registration_state_service)
) -> Dict[str, Any]:
"""
Complete registration after frontend confirms SetupIntent (3DS handled)
This is the SECOND step in registration architecture:
1. Verifies SetupIntent status
2. Creates subscription with verified payment method
3. Updates registration state
Complete registration after frontend confirms SetupIntent.
NEW ARCHITECTURE: Creates subscription HERE (not in payment-setup).
This is the ONLY place subscriptions are created during registration.
Flow:
1. Verify SetupIntent status is 'succeeded'
2. Create subscription with verified payment method
3. Update registration state
Args:
verification_data: SetupIntent verification data
verification_data: SetupIntent verification data with user_data
Returns:
Complete registration result with subscription
Raises:
HTTPException: 400 for validation errors, 500 for server errors
Subscription creation result
"""
setup_intent_id = None
user_data = {}
state_id = None
try:
# Validate required fields
if not verification_data.get('setup_intent_id'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="SetupIntent ID is required"
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="SetupIntent ID is required")
if not verification_data.get('user_data'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User data is required"
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User data is required")
setup_intent_id = verification_data['setup_intent_id']
user_data = verification_data['user_data']
state_id = verification_data.get('state_id')
logger.info(
"Completing registration after SetupIntent verification",
extra={
"email": user_data.get('email'),
"setup_intent_id": setup_intent_id
}
)
# Get registration state if provided
if state_id:
try:
registration_state = await state_service.get_registration_state(state_id)
logger.info(
"Retrieved registration state",
extra={
"state_id": state_id,
"current_state": registration_state['current_state']
}
)
except RegistrationStateError:
logger.warning("Registration state not found, proceeding without state tracking",
extra={"state_id": state_id})
state_id = None
# First verify the setup intent to get the actual customer_id and payment_method_id
verification_result = await orchestration_service.verify_setup_intent_for_registration(
setup_intent_id
)
if not verification_result.get('verified', False):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="SetupIntent verification failed"
)
logger.info("Completing registration after verification",
extra={"email": user_data.get('email'), "setup_intent_id": setup_intent_id})
# Extract actual values from verification result
actual_customer_id = verification_result.get('customer_id', user_data.get('customer_id', ''))
actual_payment_method_id = verification_result.get('payment_method_id', user_data.get('payment_method_id', ''))
# Get trial period from coupon if available
trial_period_days = user_data.get('trial_period_days', 0)
# Calculate trial period from coupon if provided in the completion call
trial_period_days = 0
coupon_code = user_data.get('coupon_code')
# If we have a coupon code but no trial period, redeem the coupon to get trial days
if coupon_code and trial_period_days == 0:
try:
from app.services.coupon_service import CouponService
coupon_service = CouponService(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:
trial_period_days = discount_applied.get("total_trial_days", 0)
logger.info("Retrieved trial period from coupon for verification",
extra={"coupon_code": coupon_code, "trial_period_days": trial_period_days})
except Exception as e:
logger.error("Failed to redeem coupon during verification, using default trial period",
extra={"coupon_code": coupon_code, "error": str(e)},
exc_info=True)
# Fall back to 0 if coupon redemption fails
trial_period_days = 0
if coupon_code:
logger.info("Validating coupon in completion call",
extra={"coupon_code": coupon_code, "email": user_data.get('email')})
# Create coupon service to validate coupon
coupon_service = CouponService(db)
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:
trial_period_days = discount_applied.get("total_trial_days", 0)
logger.info("Coupon validated in completion call",
extra={"coupon_code": coupon_code, "trial_period_days": trial_period_days})
else:
logger.warning("Failed to validate coupon in completion call",
extra={"coupon_code": coupon_code, "error": error})
elif 'trial_period_days' in user_data:
# Fallback: use trial_period_days if explicitly provided
trial_period_days = int(user_data.get('trial_period_days', 0))
logger.info("Using explicitly provided trial period",
extra={"trial_period_days": trial_period_days})
# Create subscription AFTER verification (the core fix!)
result = await orchestration_service.complete_registration_subscription(
setup_intent_id=setup_intent_id,
customer_id=user_data.get('customer_id', ''),
plan_id=user_data.get('plan_id') or user_data.get('subscription_plan', 'professional'),
payment_method_id=user_data.get('payment_method_id', ''),
billing_interval=user_data.get('billing_cycle') or user_data.get('billing_interval', 'monthly'),
trial_period_days=trial_period_days,
user_id=user_data.get('user_id')
)
# Check if a subscription already exists for this customer
existing_subscriptions = await orchestration_service.get_subscriptions_by_customer_id(actual_customer_id)
if existing_subscriptions:
# If we already have a trial subscription, update it instead of creating a new one
existing_subscription = existing_subscriptions[0] # Get the first subscription
logger.info("Found existing subscription, updating with verified payment method",
extra={
"customer_id": actual_customer_id,
"subscription_id": existing_subscription.provider_subscription_id,
"existing_status": existing_subscription.status
})
# Update the existing subscription with the verified payment method
result = await orchestration_service.update_subscription_with_verified_payment(
existing_subscription.provider_subscription_id,
actual_customer_id,
actual_payment_method_id,
trial_period_days
)
else:
# No existing subscription, create a new one
result = await orchestration_service.complete_subscription_after_setup_intent(
setup_intent_id,
actual_customer_id,
user_data.get('plan_id', 'starter'),
actual_payment_method_id, # Use the verified payment method ID
trial_period_days, # Use the trial period we obtained (90 for PILOT2025)
user_data.get('user_id') if user_data.get('user_id') else None, # Convert empty string to None
user_data.get('billing_interval', 'monthly')
)
# Update registration state if tracking
# Update registration state
if state_id:
try:
await state_service.update_state_context(state_id, {
'subscription_id': result['subscription_id'],
'status': result['status']
})
await state_service.transition_state(
state_id,
RegistrationState.SUBSCRIPTION_CREATED
)
logger.info(
"Registration state updated after subscription creation",
extra={
"state_id": state_id,
"subscription_id": result['subscription_id']
}
)
await state_service.transition_state(state_id, RegistrationState.SUBSCRIPTION_CREATED)
except Exception as e:
logger.error("Failed to update registration state after subscription creation",
extra={
"error": str(e),
"state_id": state_id
},
exc_info=True)
logger.info("Registration completed successfully after 3DS verification",
logger.warning(f"Failed to update registration state: {e}", extra={"state_id": state_id})
logger.info("Registration subscription created successfully",
extra={
"email": user_data.get('email'),
"subscription_id": result['subscription_id']
"subscription_id": result['subscription_id'],
"status": result['status']
})
return {
"success": True,
"subscription_id": result['subscription_id'],
"customer_id": result['customer_id'],
"payment_customer_id": result.get('payment_customer_id', result['customer_id']),
"status": result['status'],
"plan_id": result.get('plan_id', result.get('plan')),
"plan_id": result.get('plan_id'),
"payment_method_id": result.get('payment_method_id'),
"trial_period_days": result.get('trial_period_days'),
"trial_period_days": result.get('trial_period_days', 0),
"current_period_end": result.get('current_period_end'),
"state_id": state_id,
"message": "Registration completed successfully after 3DS verification"
"message": "Subscription created successfully"
}
except SetupIntentError as e:
logger.error("SetupIntent verification failed",
extra={
"error": str(e),
"setup_intent_id": setup_intent_id,
"email": user_data.get('email')
},
logger.error(f"SetupIntent verification failed: {e}",
extra={"setup_intent_id": setup_intent_id, "email": user_data.get('email')},
exc_info=True)
# Mark registration as failed if state tracking
if state_id:
try:
await state_service.mark_registration_failed(
state_id,
f"SetupIntent verification failed: {str(e)}"
)
await state_service.mark_registration_failed(state_id, f"Verification failed: {e}")
except Exception:
pass # Don't fail main operation for state tracking failure
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"SetupIntent verification failed: {str(e)}"
) from e
pass
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Payment verification failed: {e}") from e
except SubscriptionCreationFailed as e:
logger.error("Subscription creation failed after verification",
extra={
"error": str(e),
"setup_intent_id": setup_intent_id,
"email": user_data.get('email')
},
logger.error(f"Subscription creation failed: {e}",
extra={"setup_intent_id": setup_intent_id, "email": user_data.get('email')},
exc_info=True)
# Mark registration as failed if state tracking
if state_id:
try:
await state_service.mark_registration_failed(
state_id,
f"Subscription creation failed: {str(e)}"
)
await state_service.mark_registration_failed(state_id, f"Subscription failed: {e}")
except Exception:
pass # Don't fail main operation for state tracking failure
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Subscription creation failed: {str(e)}"
) from e
pass
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Subscription creation failed: {e}") from e
except HTTPException:
raise
except Exception as e:
logger.error("Unexpected error in registration completion",
extra={
"error": str(e),
"setup_intent_id": setup_intent_id,
"email": user_data.get('email')
},
logger.error(f"Unexpected error: {e}",
extra={"setup_intent_id": setup_intent_id, "email": user_data.get('email')},
exc_info=True)
# Mark registration as failed if state tracking
if state_id:
try:
await state_service.mark_registration_failed(
state_id,
f"Registration completion failed: {str(e)}"
)
await state_service.mark_registration_failed(state_id, f"Registration failed: {e}")
except Exception:
pass # Don't fail main operation for state tracking failure
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Registration completion failed: {str(e)}"
) from e
pass
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Registration failed: {e}") from e
@router.get("/registration-state/{state_id}",

View File

@@ -12,7 +12,9 @@ from shared.exceptions.payment_exceptions import (
SubscriptionCreationFailed,
SetupIntentError,
PaymentServiceError,
SubscriptionUpdateFailed
SubscriptionUpdateFailed,
PaymentMethodError,
CustomerUpdateFailed
)
from shared.utils.retry import retry_with_backoff
@@ -138,7 +140,7 @@ class PaymentService:
logger.error(f"Failed to set default payment method: {str(e)}, customer_id: {customer_id}, payment_method_id: {payment_method_id}",
exc_info=True)
raise PaymentServiceError(f"Default payment method update failed: {str(e)}") from e
async def create_setup_intent_for_verification(
self,
customer_id: str,
@@ -146,29 +148,27 @@ class PaymentService:
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Atomic: Create SetupIntent for payment method verification
This is the FIRST step in secure registration flow
Create SetupIntent for payment verification.
Args:
customer_id: Stripe customer ID
payment_method_id: Payment method ID to verify
metadata: Additional metadata for tracking
Returns:
SetupIntent result with verification requirements
SetupIntent result for frontend confirmation
Raises:
SetupIntentError: If SetupIntent creation fails
"""
try:
# Add registration-specific metadata
full_metadata = metadata or {}
full_metadata.update({
'service': 'tenant',
'operation': 'registration_payment_verification',
'operation': 'verification_setup_intent',
'timestamp': datetime.now().isoformat()
})
result = await retry_with_backoff(
lambda: self.stripe_client.create_setup_intent_for_verification(
customer_id, payment_method_id, full_metadata
@@ -176,39 +176,306 @@ class PaymentService:
max_retries=3,
exceptions=(SetupIntentError,)
)
logger.info("SetupIntent created for payment verification",
logger.info("SetupIntent created for verification",
setup_intent_id=result['setup_intent_id'],
customer_id=customer_id,
payment_method_id=payment_method_id,
requires_action=result['requires_action'])
requires_action=result['requires_action'],
status=result['status'])
return result
except SetupIntentError as e:
logger.error(f"SetupIntent creation failed: {str(e)}, customer_id: {customer_id}, payment_method_id: {payment_method_id}",
logger.error(f"SetupIntent creation for verification failed: {str(e)}",
extra={"customer_id": customer_id, "payment_method_id": payment_method_id},
exc_info=True)
raise
except Exception as e:
logger.error(f"Unexpected error creating SetupIntent: {str(e)}, customer_id: {customer_id}, payment_method_id: {payment_method_id}",
logger.error(f"Unexpected error creating SetupIntent for verification: {str(e)}",
extra={"customer_id": customer_id, "payment_method_id": payment_method_id},
exc_info=True)
raise SetupIntentError(f"Unexpected SetupIntent error: {str(e)}") from e
# Alias for backward compatibility
async def create_setup_intent_for_registration(
self,
customer_id: str,
payment_method_id: str,
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Create standalone SetupIntent for payment verification during registration.
This is an alias for create_setup_intent_for_verification for backward compatibility.
Args:
customer_id: Stripe customer ID
payment_method_id: Payment method ID to verify
metadata: Additional metadata for tracking
Returns:
SetupIntent result for frontend confirmation
Raises:
SetupIntentError: If SetupIntent creation fails
"""
return await self.create_setup_intent_for_verification(customer_id, payment_method_id, metadata)
async def create_setup_intent(
self
) -> Dict[str, Any]:
"""
Create a basic SetupIntent.
Returns:
SetupIntent creation result
"""
try:
result = await retry_with_backoff(
lambda: self.stripe_client.create_setup_intent(),
max_retries=3,
exceptions=(SetupIntentError,)
)
logger.info("Basic SetupIntent created",
setup_intent_id=result['setup_intent_id'],
status=result['status'])
return result
except SetupIntentError as e:
logger.error(f"Basic SetupIntent creation failed: {str(e)}",
exc_info=True)
raise
except Exception as e:
logger.error(f"Unexpected error creating basic SetupIntent: {str(e)}",
exc_info=True)
raise SetupIntentError(f"Unexpected SetupIntent error: {str(e)}") from e
async def get_setup_intent(
self,
setup_intent_id: str
) -> Any:
"""
Get SetupIntent details.
Args:
setup_intent_id: SetupIntent ID
Returns:
SetupIntent object
"""
try:
result = await retry_with_backoff(
lambda: self.stripe_client.get_setup_intent(setup_intent_id),
max_retries=3,
exceptions=(SetupIntentError,)
)
logger.info("SetupIntent retrieved",
setup_intent_id=setup_intent_id)
return result
except SetupIntentError as e:
logger.error(f"SetupIntent retrieval failed: {str(e)}",
extra={"setup_intent_id": setup_intent_id},
exc_info=True)
raise
except Exception as e:
logger.error(f"Unexpected error retrieving SetupIntent: {str(e)}",
extra={"setup_intent_id": setup_intent_id},
exc_info=True)
raise SetupIntentError(f"Unexpected SetupIntent error: {str(e)}") from e
async def create_payment_intent(
self,
amount: float,
currency: str,
customer_id: str,
payment_method_id: str
) -> Dict[str, Any]:
"""
Create a PaymentIntent for one-time payments.
Args:
amount: Payment amount
currency: Currency code
customer_id: Customer ID
payment_method_id: Payment method ID
Returns:
PaymentIntent creation result
"""
try:
result = await retry_with_backoff(
lambda: self.stripe_client.create_payment_intent(
amount, currency, customer_id, payment_method_id
),
max_retries=3,
exceptions=(PaymentVerificationError,)
)
logger.info("PaymentIntent created",
payment_intent_id=result['payment_intent_id'],
status=result['status'])
return result
except PaymentVerificationError as e:
logger.error(f"PaymentIntent creation failed: {str(e)}",
extra={
"amount": amount,
"currency": currency,
"customer_id": customer_id,
"payment_method_id": payment_method_id
},
exc_info=True)
raise
except Exception as e:
logger.error(f"Unexpected error creating PaymentIntent: {str(e)}",
extra={
"amount": amount,
"currency": currency,
"customer_id": customer_id,
"payment_method_id": payment_method_id
},
exc_info=True)
raise PaymentVerificationError(f"Unexpected payment error: {str(e)}") from e
async def create_setup_intent_for_verification(
self,
customer_id: str,
payment_method_id: str,
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Create SetupIntent for registration payment verification.
NEW ARCHITECTURE: Only creates SetupIntent, no subscription.
Subscription is created after verification completes.
Args:
customer_id: Stripe customer ID
payment_method_id: Payment method ID to verify
metadata: Additional metadata for tracking
Returns:
SetupIntent result for frontend confirmation
Raises:
SetupIntentError: If SetupIntent creation fails
"""
try:
full_metadata = metadata or {}
full_metadata.update({
'service': 'tenant',
'operation': 'registration_setup_intent',
'timestamp': datetime.now().isoformat()
})
result = await retry_with_backoff(
lambda: self.stripe_client.create_setup_intent_for_registration(
customer_id, payment_method_id, full_metadata
),
max_retries=3,
exceptions=(SetupIntentError,)
)
logger.info("SetupIntent created for registration",
setup_intent_id=result['setup_intent_id'],
customer_id=customer_id,
requires_action=result['requires_action'],
status=result['status'])
return result
except SetupIntentError as e:
logger.error(f"SetupIntent creation failed: {str(e)}",
extra={"customer_id": customer_id, "payment_method_id": payment_method_id},
exc_info=True)
raise
except Exception as e:
logger.error(f"Unexpected error creating SetupIntent: {str(e)}",
extra={"customer_id": customer_id, "payment_method_id": payment_method_id},
exc_info=True)
raise SetupIntentError(f"Unexpected SetupIntent error: {str(e)}") from e
async def create_subscription_after_verification(
self,
customer_id: str,
price_id: str,
payment_method_id: str,
trial_period_days: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Create subscription AFTER SetupIntent verification succeeds.
NEW ARCHITECTURE: Called only after payment verification completes.
Args:
customer_id: Stripe customer ID
price_id: Stripe price ID for the plan
payment_method_id: Verified payment method ID
trial_period_days: Optional trial period in days
metadata: Additional metadata
Returns:
Subscription creation result
Raises:
SubscriptionCreationFailed: If subscription creation fails
"""
try:
full_metadata = metadata or {}
full_metadata.update({
'service': 'tenant',
'operation': 'registration_subscription',
'timestamp': datetime.now().isoformat()
})
result = await retry_with_backoff(
lambda: self.stripe_client.create_subscription_after_verification(
customer_id, price_id, payment_method_id, trial_period_days, full_metadata
),
max_retries=3,
exceptions=(SubscriptionCreationFailed,)
)
logger.info("Subscription created after verification",
subscription_id=result['subscription_id'],
customer_id=customer_id,
status=result['status'],
trial_period_days=trial_period_days)
return result
except SubscriptionCreationFailed as e:
logger.error(f"Subscription creation failed: {str(e)}",
extra={"customer_id": customer_id, "price_id": price_id},
exc_info=True)
raise
except Exception as e:
logger.error(f"Unexpected error creating subscription: {str(e)}",
extra={"customer_id": customer_id, "price_id": price_id},
exc_info=True)
raise SubscriptionCreationFailed(f"Unexpected subscription error: {str(e)}") from e
async def verify_setup_intent_status(
async def verify_setup_intent(
self,
setup_intent_id: str
) -> Dict[str, Any]:
"""
Atomic: Verify SetupIntent status after frontend confirmation
Verify SetupIntent status after frontend confirmation.
Args:
setup_intent_id: SetupIntent ID to verify
Returns:
SetupIntent verification result
SetupIntent verification result with 'verified' boolean
Raises:
SetupIntentError: If verification fails
SetupIntentError: If retrieval fails
"""
try:
result = await retry_with_backoff(
@@ -216,26 +483,22 @@ class PaymentService:
max_retries=3,
exceptions=(SetupIntentError,)
)
logger.info("SetupIntent verification completed",
setup_intent_id=setup_intent_id,
status=result['status'],
verified=result.get('verified', False))
# Check if verification was successful
if not result.get('verified', False):
error_msg = result.get('last_setup_error', 'Verification failed')
logger.error(f"SetupIntent verification failed: {error_msg}, setup_intent_id: {setup_intent_id}")
raise SetupIntentError(f"SetupIntent verification failed: {error_msg}")
return result
except SetupIntentError as e:
logger.error(f"SetupIntent verification failed: {str(e)}, setup_intent_id: {setup_intent_id}",
logger.error(f"SetupIntent verification failed: {str(e)}",
extra={"setup_intent_id": setup_intent_id},
exc_info=True)
raise
except Exception as e:
logger.error(f"Unexpected error verifying SetupIntent: {str(e)}, setup_intent_id: {setup_intent_id}",
logger.error(f"Unexpected error verifying SetupIntent: {str(e)}",
extra={"setup_intent_id": setup_intent_id},
exc_info=True)
raise SetupIntentError(f"Unexpected verification error: {str(e)}") from e
@@ -884,7 +1147,7 @@ class PaymentService:
exc_info=True)
raise PaymentServiceError(f"Subscription completion failed: {str(e)}") from e
async def verify_setup_intent(
async def verify_setup_intent_status(
self,
setup_intent_id: str
) -> Dict[str, Any]:
@@ -956,6 +1219,86 @@ class PaymentService:
exc_info=True)
raise SubscriptionUpdateFailed(f"Failed to update payment method: {str(e)}") from e
async def attach_payment_method_to_customer(
self,
customer_id: str,
payment_method_id: str
) -> Any:
"""
Attach a payment method to a customer
Args:
customer_id: Stripe customer ID
payment_method_id: Payment method ID
Returns:
Updated payment method object
"""
try:
logger.info("Attaching payment method to customer",
customer_id=customer_id,
payment_method_id=payment_method_id)
payment_method = await retry_with_backoff(
lambda: self.stripe_client.attach_payment_method_to_customer(
customer_id,
payment_method_id
),
max_retries=3,
exceptions=(PaymentMethodError,)
)
logger.info("Payment method attached to customer successfully",
customer_id=customer_id,
payment_method_id=payment_method.id)
return payment_method
except Exception as e:
logger.error(f"Failed to attach payment method to customer: {str(e)}, customer_id: {customer_id}",
exc_info=True)
raise PaymentMethodError(f"Failed to attach payment method: {str(e)}") from e
async def set_customer_default_payment_method(
self,
customer_id: str,
payment_method_id: str
) -> Any:
"""
Set a payment method as the customer's default payment method
Args:
customer_id: Stripe customer ID
payment_method_id: Payment method ID
Returns:
Updated customer object
"""
try:
logger.info("Setting default payment method for customer",
customer_id=customer_id,
payment_method_id=payment_method_id)
customer = await retry_with_backoff(
lambda: self.stripe_client.set_customer_default_payment_method(
customer_id,
payment_method_id
),
max_retries=3,
exceptions=(CustomerUpdateFailed,)
)
logger.info("Default payment method set for customer successfully",
customer_id=customer.id,
payment_method_id=payment_method_id)
return customer
except Exception as e:
logger.error(f"Failed to set default payment method for customer: {str(e)}, customer_id: {customer_id}",
exc_info=True)
raise CustomerUpdateFailed(f"Failed to set default payment method: {str(e)}") from e
# Singleton instance for dependency injection
payment_service = PaymentService()

View File

@@ -5,7 +5,7 @@ This service orchestrates complex workflows involving multiple services
"""
import structlog
from typing import Dict, Any, Optional
from typing import Dict, Any, Optional, List
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
@@ -14,6 +14,7 @@ from app.services.subscription_service import SubscriptionService
from app.services.payment_service import PaymentService
from app.services.coupon_service import CouponService
from app.services.tenant_service import EnhancedTenantService
from app.models.tenants import Subscription
from app.core.config import settings
from shared.database.exceptions import DatabaseError, ValidationError
from shared.database.base import create_database_manager
@@ -1619,55 +1620,58 @@ class SubscriptionOrchestrationService:
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.
Create payment customer and SetupIntent for registration.
NEW ARCHITECTURE: Only creates customer + SetupIntent here.
Subscription is created AFTER SetupIntent verification completes.
Flow:
1. Create Stripe customer
2. Handle coupon (get trial days)
3. Create SetupIntent for payment verification
4. Return SetupIntent to frontend for 3DS handling
5. (Later) complete_registration_subscription() creates subscription
Args:
user_data: User data (email, full_name, etc.) - NO user_id required
user_data: User data (email, full_name, etc.)
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
Dictionary with SetupIntent data for frontend
Raises:
Exception: If payment setup fails
"""
try:
logger.info("Starting registration payment setup (pre-user-creation)",
logger.info("Starting registration payment setup",
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
# Step 1: Create payment customer
email = user_data.get('email')
name = user_data.get('full_name')
metadata = {
'registration_flow': 'pre_user_creation',
'registration_flow': 'setup_intent_first',
'plan_id': plan_id,
'billing_interval': billing_interval,
'timestamp': datetime.now(timezone.utc).isoformat()
}
customer = await self.payment_service.create_customer(email, name, metadata)
logger.info("Payment customer created for registration",
logger.info("Customer created for registration",
customer_id=customer.id,
email=user_data.get('email'))
email=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",
logger.info("Validating coupon for registration",
coupon_code=coupon_code,
email=user_data.get('email'))
email=email)
coupon_service = CouponService(self.db_session)
success, discount_applied, error = await coupon_service.redeem_coupon(
@@ -1677,82 +1681,55 @@ class SubscriptionOrchestrationService:
)
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",
logger.info("Coupon validated for registration",
coupon_code=coupon_code,
trial_period_days=trial_period_days)
else:
logger.warning("Failed to redeem coupon for registration, continuing without it",
logger.warning("Failed to validate coupon, continuing without it",
coupon_code=coupon_code,
error=error)
# Step 3: Create subscription/SetupIntent
logger.info("Creating subscription/SetupIntent for registration",
# Step 3: Create SetupIntent (NO subscription yet!)
logger.info("Creating SetupIntent for registration",
customer_id=customer.id,
plan_id=plan_id,
payment_method_id=payment_method_id)
# Get the Stripe price ID for this plan
price_id = self.payment_service._get_stripe_price_id(plan_id, billing_interval)
subscription_result = await self.payment_service.create_subscription_with_verified_payment(
setup_result = await self.payment_service.create_setup_intent_for_registration(
customer.id,
price_id,
payment_method_id,
trial_period_days if trial_period_days > 0 else None,
billing_interval
{
'purpose': 'registration',
'plan_id': plan_id,
'billing_interval': billing_interval,
'trial_period_days': str(trial_period_days),
'coupon_code': coupon_code or ''
}
)
# 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'),
subscription_id=subscription_result.get('subscription_id'))
logger.info("SetupIntent created for registration",
setup_intent_id=setup_result.get('setup_intent_id'),
requires_action=setup_result.get('requires_action'),
status=setup_result.get('status'))
# Return the SetupIntent data for frontend to handle 3DS
# Note: subscription_id is included because for trial subscriptions,
# the subscription is already created in 'trialing' status even though
# the SetupIntent requires 3DS verification for future payments
return {
"requires_action": True,
"action_type": subscription_result.get('action_type') or 'use_stripe_sdk',
"client_secret": subscription_result.get('client_secret'),
"setup_intent_id": subscription_result.get('setup_intent_id'),
"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": 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"
}
# Return result for frontend
# Frontend will call complete_registration_subscription() after 3DS
return {
"requires_action": setup_result.get('requires_action', True),
"action_type": "use_stripe_sdk",
"client_secret": setup_result.get('client_secret'),
"setup_intent_id": setup_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_code": coupon_code,
"email": email,
"full_name": name,
"message": "Payment verification required" if setup_result.get('requires_action') else "Payment verified"
}
except Exception as e:
logger.error("Registration payment setup failed",
@@ -1766,41 +1743,143 @@ class SubscriptionOrchestrationService:
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.
Verify SetupIntent status for registration completion.
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",
logger.info("Verifying SetupIntent for registration",
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",
logger.info("SetupIntent verification result",
setup_intent_id=setup_intent_id,
status=verification_result.get('status'))
status=verification_result.get('status'),
verified=verification_result.get('verified'))
return verification_result
except Exception as e:
logger.error("SetupIntent verification failed for registration",
logger.error("SetupIntent verification failed",
setup_intent_id=setup_intent_id,
error=str(e),
exc_info=True)
raise
async def complete_registration_subscription(
self,
setup_intent_id: str,
customer_id: str,
plan_id: str,
payment_method_id: str,
billing_interval: str = "monthly",
trial_period_days: int = 0,
user_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Create subscription AFTER SetupIntent verification succeeds.
NEW ARCHITECTURE: This is called AFTER 3DS verification completes.
The subscription is created here, not during payment setup.
Args:
setup_intent_id: Verified SetupIntent ID
customer_id: Stripe customer ID
plan_id: Subscription plan ID
payment_method_id: Verified payment method ID
billing_interval: Billing interval (monthly/yearly)
trial_period_days: Trial period in days (from coupon)
user_id: Optional user ID if user already created
Returns:
Dictionary with subscription details
Raises:
Exception: If subscription creation fails
"""
try:
logger.info("Creating subscription after verification",
setup_intent_id=setup_intent_id,
customer_id=customer_id,
plan_id=plan_id,
trial_period_days=trial_period_days)
# Verify SetupIntent is successful
verification = await self.payment_service.verify_setup_intent(setup_intent_id)
if not verification.get('verified'):
raise ValidationError(
f"SetupIntent not verified. Status: {verification.get('status')}"
)
# Get actual customer_id and payment_method_id from verification
actual_customer_id = verification.get('customer_id') or customer_id
actual_payment_method_id = verification.get('payment_method_id') or payment_method_id
# Get price ID for the plan
price_id = self.payment_service._get_stripe_price_id(plan_id, billing_interval)
# Create subscription in Stripe
subscription_result = await self.payment_service.create_subscription_after_verification(
actual_customer_id,
price_id,
actual_payment_method_id,
trial_period_days if trial_period_days > 0 else None,
{
'plan_id': plan_id,
'billing_interval': billing_interval,
'created_via': 'registration_flow',
'setup_intent_id': setup_intent_id
}
)
logger.info("Subscription created after verification",
subscription_id=subscription_result.get('subscription_id'),
status=subscription_result.get('status'),
trial_period_days=trial_period_days)
# Create local subscription record (without tenant_id for now)
subscription_record = await self.subscription_service.create_tenant_independent_subscription_record(
subscription_result['subscription_id'],
actual_customer_id,
plan_id,
subscription_result['status'],
trial_period_days,
billing_interval,
user_id
)
logger.info("Subscription record created",
subscription_id=subscription_result['subscription_id'],
record_id=str(subscription_record.id) if subscription_record else None)
return {
'subscription_id': subscription_result['subscription_id'],
'customer_id': actual_customer_id,
'payment_customer_id': actual_customer_id,
'payment_method_id': actual_payment_method_id,
'status': subscription_result['status'],
'plan_id': plan_id,
'trial_period_days': trial_period_days,
'current_period_end': subscription_result.get('current_period_end'),
'message': 'Subscription created successfully'
}
except Exception as e:
logger.error("Subscription creation after verification failed",
setup_intent_id=setup_intent_id,
customer_id=customer_id,
error=str(e),
exc_info=True)
raise
async def validate_plan_upgrade(
self,
tenant_id: str,
@@ -1878,10 +1957,14 @@ class SubscriptionOrchestrationService:
trial_period_days: Optional[int] = None
) -> Dict[str, Any]:
"""
Update an existing subscription with a verified payment method
Update an existing trial subscription with a verified payment method
This is used when we already have a trial subscription and just need to
attach the verified payment method to it.
This is used when we already have a trial subscription (created during registration)
and just need to attach the verified payment method to it after 3DS verification.
For trial subscriptions, the payment method should be:
1. Attached to the customer (for trial period)
2. Set as default payment method on the subscription (for future billing)
Args:
subscription_id: Stripe subscription ID
@@ -1893,10 +1976,11 @@ class SubscriptionOrchestrationService:
Dictionary with updated subscription details
"""
try:
logger.info("Updating existing subscription with verified payment method",
logger.info("Updating existing trial subscription with verified payment method",
subscription_id=subscription_id,
customer_id=customer_id,
payment_method_id=payment_method_id)
payment_method_id=payment_method_id,
trial_period_days=trial_period_days)
# First, verify the subscription exists and get its current status
existing_subscription = await self.subscription_service.get_subscription_by_provider_id(subscription_id)
@@ -1904,19 +1988,46 @@ class SubscriptionOrchestrationService:
if not existing_subscription:
raise SubscriptionNotFound(f"Subscription {subscription_id} not found")
# Update the subscription in Stripe with the verified payment method
# For trial subscriptions, we need to:
# 1. Ensure payment method is attached to customer
# 2. Set it as default payment method on subscription
# Step 1: Attach payment method to customer (if not already attached)
try:
await self.payment_service.attach_payment_method_to_customer(
customer_id,
payment_method_id
)
logger.info("Payment method attached to customer for trial subscription",
customer_id=customer_id,
payment_method_id=payment_method_id)
except Exception as e:
logger.warning("Payment method may already be attached to customer",
customer_id=customer_id,
payment_method_id=payment_method_id,
error=str(e))
# Step 2: Set payment method as default on subscription
stripe_subscription = await self.payment_service.update_subscription_payment_method(
subscription_id,
payment_method_id
)
# Step 3: Also set as default payment method on customer for future invoices
await self.payment_service.set_customer_default_payment_method(
customer_id,
payment_method_id
)
# Update our local subscription record
await self.subscription_service.update_subscription_status(
existing_subscription.tenant_id,
stripe_subscription.status,
{
'current_period_start': datetime.fromtimestamp(stripe_subscription.current_period_start),
'current_period_end': datetime.fromtimestamp(stripe_subscription.current_period_end)
'current_period_end': datetime.fromtimestamp(stripe_subscription.current_period_end),
'payment_method_verified': True,
'payment_method_id': payment_method_id
}
)
@@ -1940,14 +2051,17 @@ class SubscriptionOrchestrationService:
'verification': {
'verified': True,
'customer_id': customer_id,
'payment_method_id': payment_method_id
}
'payment_method_id': payment_method_id,
'trial_period_days': trial_period_days
},
'trial_preserved': True,
'payment_method_updated': True
}
except Exception as e:
logger.error("Failed to update subscription with verified payment",
logger.error("Failed to update trial subscription with verified payment",
subscription_id=subscription_id,
customer_id=customer_id,
error=str(e),
exc_info=True)
raise SubscriptionUpdateFailed(f"Failed to update subscription: {str(e)}")
raise SubscriptionUpdateFailed(f"Failed to update trial subscription: {str(e)}")