Add subcription feature 2
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user