1316 lines
51 KiB
Python
1316 lines
51 KiB
Python
"""
|
|
Atomic Payment Service with proper 3DS/3DS2 support
|
|
Implements SetupIntent-first architecture for secure payment flows
|
|
"""
|
|
|
|
import structlog
|
|
from typing import Dict, Any, Optional
|
|
from datetime import datetime
|
|
from shared.clients.stripe_client import stripe_client
|
|
from shared.exceptions.payment_exceptions import (
|
|
PaymentVerificationError,
|
|
SubscriptionCreationFailed,
|
|
SetupIntentError,
|
|
PaymentServiceError,
|
|
SubscriptionUpdateFailed,
|
|
PaymentMethodError,
|
|
CustomerUpdateFailed
|
|
)
|
|
from shared.utils.retry import retry_with_backoff
|
|
|
|
# Configure logging
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
class PaymentService:
|
|
"""
|
|
Atomic Payment Service with proper 3DS/3DS2 support
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""Initialize payment service"""
|
|
self.stripe_client = stripe_client
|
|
|
|
async def create_customer(
|
|
self,
|
|
email: str,
|
|
name: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Atomic: Create Stripe customer
|
|
|
|
Args:
|
|
email: Customer email
|
|
name: Customer name
|
|
metadata: Additional metadata
|
|
|
|
Returns:
|
|
Customer creation result
|
|
|
|
Raises:
|
|
PaymentServiceError: If customer creation fails
|
|
"""
|
|
try:
|
|
result = await retry_with_backoff(
|
|
lambda: self.stripe_client.create_customer(email, name, metadata),
|
|
max_retries=3,
|
|
exceptions=(Exception,)
|
|
)
|
|
|
|
logger.info("Customer created successfully",
|
|
customer_id=result.id,
|
|
email=email)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to create customer: {str(e)}, email: {email}",
|
|
exc_info=True)
|
|
raise PaymentServiceError(f"Customer creation failed: {str(e)}") from e
|
|
|
|
async def attach_payment_method(
|
|
self,
|
|
payment_method_id: str,
|
|
customer_id: str
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Atomic: Attach payment method to customer
|
|
|
|
Args:
|
|
payment_method_id: Payment method ID
|
|
customer_id: Customer ID
|
|
|
|
Returns:
|
|
Payment method attachment result
|
|
|
|
Raises:
|
|
PaymentServiceError: If attachment fails
|
|
"""
|
|
try:
|
|
result = await retry_with_backoff(
|
|
lambda: self.stripe_client.attach_payment_method(payment_method_id, customer_id),
|
|
max_retries=3,
|
|
exceptions=(Exception,)
|
|
)
|
|
|
|
logger.info("Payment method attached successfully",
|
|
payment_method_id=payment_method_id,
|
|
customer_id=customer_id)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to attach payment method: {str(e)}, payment_method_id: {payment_method_id}, customer_id: {customer_id}",
|
|
exc_info=True)
|
|
raise PaymentServiceError(f"Payment method attachment failed: {str(e)}") from e
|
|
|
|
async def set_default_payment_method(
|
|
self,
|
|
customer_id: str,
|
|
payment_method_id: str
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Atomic: Set default payment method for customer
|
|
|
|
Args:
|
|
customer_id: Customer ID
|
|
payment_method_id: Payment method ID
|
|
|
|
Returns:
|
|
Default payment method update result
|
|
|
|
Raises:
|
|
PaymentServiceError: If update fails
|
|
"""
|
|
try:
|
|
result = await retry_with_backoff(
|
|
lambda: self.stripe_client.set_default_payment_method(customer_id, payment_method_id),
|
|
max_retries=3,
|
|
exceptions=(Exception,)
|
|
)
|
|
|
|
logger.info("Default payment method set successfully",
|
|
customer_id=customer_id,
|
|
payment_method_id=payment_method_id)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
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,
|
|
payment_method_id: str,
|
|
metadata: Optional[Dict[str, Any]] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
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 for frontend confirmation
|
|
|
|
Raises:
|
|
SetupIntentError: If SetupIntent creation fails
|
|
"""
|
|
try:
|
|
full_metadata = metadata or {}
|
|
full_metadata.update({
|
|
'service': 'tenant',
|
|
'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
|
|
),
|
|
max_retries=3,
|
|
exceptions=(SetupIntentError,)
|
|
)
|
|
|
|
logger.info("SetupIntent created for verification",
|
|
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 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 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(
|
|
self,
|
|
setup_intent_id: str
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Verify SetupIntent status after frontend confirmation.
|
|
|
|
Args:
|
|
setup_intent_id: SetupIntent ID to verify
|
|
|
|
Returns:
|
|
SetupIntent verification result with 'verified' boolean
|
|
|
|
Raises:
|
|
SetupIntentError: If retrieval fails
|
|
"""
|
|
try:
|
|
result = await retry_with_backoff(
|
|
lambda: self.stripe_client.verify_setup_intent_status(setup_intent_id),
|
|
max_retries=3,
|
|
exceptions=(SetupIntentError,)
|
|
)
|
|
|
|
logger.info("SetupIntent verification completed",
|
|
setup_intent_id=setup_intent_id,
|
|
status=result['status'],
|
|
verified=result.get('verified', False))
|
|
|
|
return result
|
|
|
|
except SetupIntentError as e:
|
|
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)}",
|
|
extra={"setup_intent_id": setup_intent_id},
|
|
exc_info=True)
|
|
raise SetupIntentError(f"Unexpected verification error: {str(e)}") from e
|
|
|
|
async def create_subscription_with_verified_payment(
|
|
self,
|
|
customer_id: str,
|
|
price_id: str,
|
|
payment_method_id: str,
|
|
trial_period_days: Optional[int] = None,
|
|
billing_cycle_anchor: Optional[int] = None,
|
|
metadata: Optional[Dict[str, Any]] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Atomic: Create subscription with verified payment method
|
|
ONLY called after successful SetupIntent verification
|
|
|
|
Args:
|
|
customer_id: Stripe customer ID
|
|
price_id: Stripe price ID
|
|
payment_method_id: Verified payment method ID
|
|
trial_period_days: Optional trial period
|
|
billing_cycle_anchor: Optional billing cycle anchor
|
|
metadata: Additional metadata
|
|
|
|
Returns:
|
|
Subscription creation result
|
|
|
|
Raises:
|
|
SubscriptionCreationFailed: If subscription creation fails
|
|
"""
|
|
try:
|
|
# Add registration-specific metadata
|
|
full_metadata = metadata or {}
|
|
full_metadata.update({
|
|
'service': 'tenant',
|
|
'operation': 'registration_subscription_creation',
|
|
'timestamp': datetime.now().isoformat()
|
|
})
|
|
|
|
result = await retry_with_backoff(
|
|
lambda: self.stripe_client.create_subscription_with_verified_payment(
|
|
customer_id, price_id, payment_method_id, trial_period_days,
|
|
billing_cycle_anchor, full_metadata
|
|
),
|
|
max_retries=3,
|
|
exceptions=(SubscriptionCreationFailed,)
|
|
)
|
|
|
|
logger.info("Subscription created successfully",
|
|
subscription_id=result['subscription_id'],
|
|
customer_id=customer_id,
|
|
status=result['status'],
|
|
trial_period_days=trial_period_days)
|
|
|
|
# CRITICAL FIX: Ensure that subscriptions with trial periods don't have immediate payment attempts
|
|
# IMPORTANT: This validation distinguishes between 3DS verification (GOOD) and immediate payment (BAD)
|
|
if trial_period_days and trial_period_days > 0:
|
|
# For trial subscriptions, we need to ensure no immediate payment is attempted
|
|
# This is a safeguard against Stripe creating invoices during trial periods
|
|
# However, we MUST allow requires_action=True when it's for 3DS verification (SetupIntent)
|
|
if result.get('requires_action', False):
|
|
# Check if this is for 3DS verification (setup_intent) vs immediate payment (payment_intent)
|
|
# - setup_intent_id present = 3DS verification needed = ALLOW (frontend handles verification)
|
|
# - payment_intent_id present = immediate payment needed = REJECT (shouldn't happen for trials)
|
|
# - no intent IDs = unexpected error = REJECT
|
|
setup_intent_id = result.get('setup_intent_id')
|
|
payment_intent_id = result.get('payment_intent_id')
|
|
|
|
if setup_intent_id:
|
|
# This is 3DS verification - this is EXPECTED and GOOD
|
|
logger.info("Trial subscription requires 3DS verification via SetupIntent",
|
|
subscription_id=result['subscription_id'],
|
|
trial_period_days=trial_period_days,
|
|
setup_intent_id=setup_intent_id)
|
|
elif payment_intent_id:
|
|
# This is immediate payment - this is BAD for trials
|
|
logger.warning("Subscription with trial period requires immediate payment action - this should not happen!",
|
|
subscription_id=result['subscription_id'],
|
|
trial_period_days=trial_period_days,
|
|
payment_intent_id=payment_intent_id)
|
|
|
|
# This is a critical error - trial periods should not require immediate payment
|
|
raise SubscriptionCreationFailed(
|
|
f"Subscription with {trial_period_days} day trial requires immediate payment. "
|
|
f"This indicates a Stripe configuration issue. "
|
|
f"Subscription ID: {result['subscription_id']}"
|
|
)
|
|
else:
|
|
# requires_action=True but no setup_intent_id or payment_intent_id - this is unexpected
|
|
logger.warning("Subscription with trial period requires action but has no setup_intent_id or payment_intent_id",
|
|
subscription_id=result['subscription_id'],
|
|
trial_period_days=trial_period_days)
|
|
raise SubscriptionCreationFailed(
|
|
f"Subscription with {trial_period_days} day trial requires action but has no associated intent. "
|
|
f"Subscription ID: {result['subscription_id']}"
|
|
)
|
|
|
|
# Ensure subscription status is correct for trial periods
|
|
if result.get('status') not in ['trialing', 'active']:
|
|
logger.error("Invalid subscription status for trial period",
|
|
subscription_id=result['subscription_id'],
|
|
status=result.get('status'),
|
|
trial_period_days=trial_period_days)
|
|
raise SubscriptionCreationFailed(
|
|
f"Invalid subscription status '{result.get('status')}' for trial period. "
|
|
f"Expected 'trialing' or 'active'. Subscription ID: {result['subscription_id']}"
|
|
)
|
|
|
|
return result
|
|
|
|
except SubscriptionCreationFailed as e:
|
|
logger.error(f"Subscription creation failed: {str(e)}, 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)}, customer_id: {customer_id}, price_id: {price_id}",
|
|
exc_info=True)
|
|
raise SubscriptionCreationFailed(f"Unexpected subscription error: {str(e)}") from e
|
|
|
|
async def complete_registration_payment_flow(
|
|
self,
|
|
user_data: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Complete registration payment flow with atomic operations
|
|
This orchestrates the entire payment verification and subscription creation
|
|
|
|
Args:
|
|
user_data: User registration data including payment info
|
|
|
|
Returns:
|
|
Complete registration payment result
|
|
|
|
Raises:
|
|
PaymentServiceError: If any step fails
|
|
"""
|
|
try:
|
|
# Extract required data
|
|
email = user_data['email']
|
|
full_name = user_data.get('full_name')
|
|
payment_method_id = user_data['payment_method_id']
|
|
plan_id = user_data['plan_id']
|
|
billing_cycle = user_data.get('billing_cycle', 'monthly')
|
|
coupon_code = user_data.get('coupon_code')
|
|
|
|
# Step 1: Create customer
|
|
logger.info("Step 1: Creating Stripe customer for registration",
|
|
email=email)
|
|
|
|
customer_result = await self.create_customer(
|
|
email=email,
|
|
name=full_name,
|
|
metadata={
|
|
'registration_flow': 'secure_registration',
|
|
'plan_id': plan_id
|
|
}
|
|
)
|
|
customer_id = customer_result['customer_id']
|
|
|
|
# Step 2: Attach payment method
|
|
logger.info("Step 2: Attaching payment method to customer",
|
|
customer_id=customer_id,
|
|
payment_method_id=payment_method_id)
|
|
|
|
await self.attach_payment_method(
|
|
payment_method_id=payment_method_id,
|
|
customer_id=customer_id
|
|
)
|
|
|
|
# Step 3: Set as default payment method
|
|
logger.info("Step 3: Setting default payment method",
|
|
customer_id=customer_id,
|
|
payment_method_id=payment_method_id)
|
|
|
|
await self.set_default_payment_method(
|
|
customer_id=customer_id,
|
|
payment_method_id=payment_method_id
|
|
)
|
|
|
|
# Step 4: Create SetupIntent for verification
|
|
logger.info("Step 4: Creating SetupIntent for payment verification",
|
|
customer_id=customer_id,
|
|
payment_method_id=payment_method_id)
|
|
|
|
setup_intent_result = await self.create_setup_intent_for_verification(
|
|
customer_id=customer_id,
|
|
payment_method_id=payment_method_id,
|
|
metadata={
|
|
'email': email,
|
|
'plan_id': plan_id,
|
|
'billing_cycle': billing_cycle
|
|
}
|
|
)
|
|
|
|
# Return result with SetupIntent for frontend to handle
|
|
# Frontend will confirm SetupIntent and handle 3DS if required
|
|
return {
|
|
'success': True,
|
|
'customer_id': customer_id,
|
|
'payment_customer_id': customer_id,
|
|
'setup_intent_id': setup_intent_result['setup_intent_id'],
|
|
'client_secret': setup_intent_result['client_secret'],
|
|
'requires_action': setup_intent_result['requires_action'],
|
|
'status': setup_intent_result['status'],
|
|
'email': email,
|
|
'plan_id': plan_id,
|
|
'payment_method_id': payment_method_id,
|
|
'billing_cycle': billing_cycle,
|
|
'message': 'Payment verification required. Frontend must confirm SetupIntent.'
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Registration payment flow failed: {str(e)}, email: {user_data.get('email')}",
|
|
exc_info=True)
|
|
raise PaymentServiceError(f"Registration payment flow failed: {str(e)}") from e
|
|
|
|
async def complete_subscription_after_verification(
|
|
self,
|
|
setup_intent_id: str,
|
|
user_data: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Complete subscription creation after successful SetupIntent verification
|
|
|
|
Args:
|
|
setup_intent_id: Verified SetupIntent ID
|
|
user_data: User registration data
|
|
|
|
Returns:
|
|
Subscription creation result
|
|
|
|
Raises:
|
|
PaymentServiceError: If subscription creation fails
|
|
"""
|
|
try:
|
|
# Step 1: Verify SetupIntent
|
|
logger.info("Verifying SetupIntent before subscription creation",
|
|
setup_intent_id=setup_intent_id)
|
|
|
|
verification_result = await self.verify_setup_intent_status(setup_intent_id)
|
|
|
|
if not verification_result.get('verified', False):
|
|
raise PaymentServiceError("SetupIntent verification failed before subscription creation")
|
|
|
|
customer_id = verification_result['customer_id']
|
|
payment_method_id = verification_result['payment_method_id']
|
|
|
|
# Step 2: Get plan details and price ID
|
|
plan_id = user_data['plan_id']
|
|
billing_cycle = user_data.get('billing_cycle', 'monthly')
|
|
|
|
logger.info(f"Mapping plan to Stripe price ID",
|
|
plan_id=plan_id,
|
|
billing_cycle=billing_cycle)
|
|
|
|
# Map plan to Stripe price ID (this would come from config in real implementation)
|
|
price_id = self._get_stripe_price_id(plan_id, billing_cycle)
|
|
|
|
logger.info(f"Retrieved Stripe price ID",
|
|
plan_id=plan_id,
|
|
billing_cycle=billing_cycle,
|
|
price_id=price_id)
|
|
|
|
# Step 3: Create subscription with verified payment method
|
|
logger.info("Creating subscription with verified payment method",
|
|
customer_id=customer_id,
|
|
plan_id=plan_id,
|
|
payment_method_id=payment_method_id)
|
|
|
|
subscription_result = await self.create_subscription_with_verified_payment(
|
|
customer_id=customer_id,
|
|
price_id=price_id,
|
|
payment_method_id=payment_method_id,
|
|
trial_period_days=user_data.get('trial_period_days'),
|
|
metadata={
|
|
'registration_flow': 'secure_registration',
|
|
'email': user_data.get('email'),
|
|
'plan_id': plan_id
|
|
}
|
|
)
|
|
|
|
return {
|
|
'success': True,
|
|
'subscription_id': subscription_result['subscription_id'],
|
|
'customer_id': customer_id,
|
|
'payment_customer_id': customer_id,
|
|
'status': subscription_result['status'],
|
|
'plan_id': plan_id,
|
|
'payment_method_id': payment_method_id,
|
|
'trial_period_days': subscription_result.get('trial_period_days'),
|
|
'current_period_end': subscription_result['current_period_end'],
|
|
'message': 'Subscription created successfully with verified payment method'
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Subscription creation after verification failed: {str(e)}, setup_intent_id: {setup_intent_id}",
|
|
exc_info=True)
|
|
raise PaymentServiceError(f"Subscription creation failed: {str(e)}") from e
|
|
|
|
def _get_stripe_price_id(self, plan_id: str, billing_cycle: str) -> str:
|
|
"""
|
|
Get Stripe price ID for a given plan and billing cycle
|
|
Uses configuration from tenant service settings
|
|
"""
|
|
try:
|
|
from app.core.config import settings
|
|
|
|
# Use the configured price ID mapping
|
|
price_id = settings.STRIPE_PRICE_ID_MAPPING.get((plan_id.lower(), billing_cycle.lower()))
|
|
|
|
logger.info(f"Getting Stripe price ID for plan {plan_id} ({billing_cycle})",
|
|
price_id=price_id,
|
|
plan_id=plan_id,
|
|
billing_cycle=billing_cycle)
|
|
|
|
if not price_id:
|
|
raise PaymentServiceError(f"No Stripe price ID found for plan {plan_id} with {billing_cycle} billing")
|
|
|
|
return price_id
|
|
except ImportError:
|
|
# Fallback to hardcoded values if config import fails
|
|
# This should only happen in testing environments
|
|
# Use actual Stripe price IDs from config
|
|
logger.warning("Using fallback price ID mapping - config import failed")
|
|
|
|
price_mapping = {
|
|
('starter', 'monthly'): 'price_1Sp0p3IzCdnBmAVT2Gs7z5np',
|
|
('starter', 'yearly'): 'price_1Sp0twIzCdnBmAVTD1lNLedx',
|
|
('professional', 'monthly'): 'price_1Sp0w7IzCdnBmAVTp0Jxhh1u',
|
|
('professional', 'yearly'): 'price_1Sp0yAIzCdnBmAVTLoGl4QCb',
|
|
('enterprise', 'monthly'): 'price_1Sp0zAIzCdnBmAVTXpApF7YO',
|
|
('enterprise', 'yearly'): 'price_1Sp15mIzCdnBmAVTuxffMpV5',
|
|
}
|
|
|
|
price_id = price_mapping.get((plan_id.lower(), billing_cycle.lower()))
|
|
|
|
logger.info(f"Fallback: Getting Stripe price ID for plan {plan_id} ({billing_cycle})",
|
|
price_id=price_id,
|
|
plan_id=plan_id,
|
|
billing_cycle=billing_cycle)
|
|
|
|
if not price_id:
|
|
raise PaymentServiceError(f"No Stripe price ID found for plan {plan_id} with {billing_cycle} billing")
|
|
|
|
return price_id
|
|
|
|
async def update_payment_subscription(
|
|
self,
|
|
subscription_id: str,
|
|
new_price_id: str,
|
|
proration_behavior: str = "create_prorations",
|
|
billing_cycle_anchor: Optional[str] = None,
|
|
payment_behavior: str = "error_if_incomplete",
|
|
immediate_change: bool = True,
|
|
preserve_trial: bool = False
|
|
) -> Any:
|
|
"""
|
|
Update subscription price (plan upgrade/downgrade)
|
|
|
|
Args:
|
|
subscription_id: Stripe subscription ID
|
|
new_price_id: New Stripe price ID
|
|
proration_behavior: How to handle proration ('create_prorations', 'none', 'always_invoice')
|
|
billing_cycle_anchor: Billing cycle anchor ("now" or "unchanged")
|
|
payment_behavior: Payment behavior on update
|
|
immediate_change: Whether to apply changes immediately
|
|
preserve_trial: If True, preserves the trial period after upgrade
|
|
|
|
Returns:
|
|
Updated subscription object with .id, .status, etc. attributes
|
|
"""
|
|
try:
|
|
result = await retry_with_backoff(
|
|
lambda: self.stripe_client.update_subscription(
|
|
subscription_id,
|
|
new_price_id,
|
|
proration_behavior=proration_behavior,
|
|
preserve_trial=preserve_trial
|
|
),
|
|
max_retries=3,
|
|
exceptions=(SubscriptionCreationFailed,)
|
|
)
|
|
|
|
logger.info("Subscription updated successfully",
|
|
subscription_id=subscription_id,
|
|
new_price_id=new_price_id,
|
|
proration_behavior=proration_behavior,
|
|
preserve_trial=preserve_trial,
|
|
is_trialing=result.get('is_trialing', False))
|
|
|
|
# Create wrapper object for compatibility with callers expecting .id, .status etc.
|
|
class SubscriptionWrapper:
|
|
def __init__(self, data: dict):
|
|
self.id = data.get('subscription_id')
|
|
self.status = data.get('status')
|
|
self.current_period_start = data.get('current_period_start')
|
|
self.current_period_end = data.get('current_period_end')
|
|
self.customer = data.get('customer_id')
|
|
self.trial_end = data.get('trial_end')
|
|
self.is_trialing = data.get('is_trialing', False)
|
|
|
|
return SubscriptionWrapper(result)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to update subscription: {str(e)}, subscription_id: {subscription_id}",
|
|
exc_info=True)
|
|
raise PaymentServiceError(f"Subscription update failed: {str(e)}") from e
|
|
|
|
async def cancel_payment_subscription(
|
|
self,
|
|
subscription_id: str
|
|
) -> Any:
|
|
"""
|
|
Cancel a subscription at period end
|
|
|
|
Args:
|
|
subscription_id: Stripe subscription ID
|
|
|
|
Returns:
|
|
Cancelled subscription object
|
|
"""
|
|
try:
|
|
result = await retry_with_backoff(
|
|
lambda: self.stripe_client.cancel_subscription(subscription_id),
|
|
max_retries=3,
|
|
exceptions=(SubscriptionCreationFailed,)
|
|
)
|
|
|
|
logger.info("Subscription cancelled successfully",
|
|
subscription_id=subscription_id)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to cancel subscription: {str(e)}, subscription_id: {subscription_id}",
|
|
exc_info=True)
|
|
raise PaymentServiceError(f"Subscription cancellation failed: {str(e)}") from e
|
|
|
|
async def calculate_payment_proration(
|
|
self,
|
|
subscription_id: str,
|
|
new_price_id: str,
|
|
proration_behavior: str = "create_prorations"
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Calculate proration for subscription plan change
|
|
|
|
Args:
|
|
subscription_id: Current subscription ID
|
|
new_price_id: New price ID to change to
|
|
proration_behavior: Proration behavior
|
|
|
|
Returns:
|
|
Proration calculation details
|
|
"""
|
|
try:
|
|
# Get current subscription to calculate proration
|
|
subscription = await self.stripe_client.get_subscription(subscription_id)
|
|
|
|
# For now, return a simple proration estimate
|
|
# In production, use Stripe's upcoming invoice preview
|
|
return {
|
|
'subscription_id': subscription_id,
|
|
'new_price_id': new_price_id,
|
|
'proration_behavior': proration_behavior,
|
|
'net_amount': 0, # Placeholder - should use Stripe's invoice preview
|
|
'calculated_at': datetime.now().isoformat()
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to calculate proration: {str(e)}, subscription_id: {subscription_id}",
|
|
exc_info=True)
|
|
raise PaymentServiceError(f"Proration calculation failed: {str(e)}") from e
|
|
|
|
async def change_billing_cycle(
|
|
self,
|
|
subscription_id: str,
|
|
new_billing_cycle: str,
|
|
proration_behavior: str = "create_prorations"
|
|
) -> Any:
|
|
"""
|
|
Change subscription billing cycle
|
|
|
|
Args:
|
|
subscription_id: Subscription ID
|
|
new_billing_cycle: New billing cycle (monthly/yearly)
|
|
proration_behavior: How to handle proration
|
|
|
|
Returns:
|
|
Updated subscription object with .id, .status, etc. attributes
|
|
"""
|
|
try:
|
|
# Get current subscription
|
|
subscription = await self.stripe_client.get_subscription(subscription_id)
|
|
|
|
# This would need to get the correct price ID for the new billing cycle
|
|
logger.info("Billing cycle change requested",
|
|
subscription_id=subscription_id,
|
|
new_billing_cycle=new_billing_cycle,
|
|
proration_behavior=proration_behavior)
|
|
|
|
# Create wrapper object for compatibility
|
|
class SubscriptionWrapper:
|
|
def __init__(self, data: dict):
|
|
self.id = data.get('subscription_id')
|
|
self.status = data.get('status')
|
|
self.current_period_start = data.get('current_period_start')
|
|
self.current_period_end = data.get('current_period_end')
|
|
self.customer = data.get('customer_id')
|
|
|
|
return SubscriptionWrapper(subscription)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to change billing cycle: {str(e)}, subscription_id: {subscription_id}",
|
|
exc_info=True)
|
|
raise PaymentServiceError(f"Billing cycle change failed: {str(e)}") from e
|
|
|
|
async def get_customer_payment_method(
|
|
self,
|
|
customer_id: str
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get customer's default payment method
|
|
|
|
Args:
|
|
customer_id: Stripe customer ID
|
|
|
|
Returns:
|
|
Payment method details or None
|
|
"""
|
|
try:
|
|
result = await retry_with_backoff(
|
|
lambda: self.stripe_client.get_customer_payment_method(customer_id),
|
|
max_retries=3,
|
|
exceptions=(Exception,)
|
|
)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get customer payment method: {str(e)}, customer_id: {customer_id}",
|
|
exc_info=True)
|
|
return None
|
|
|
|
async def update_payment_method(
|
|
self,
|
|
customer_id: str,
|
|
payment_method_id: str
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Update customer's payment method
|
|
|
|
Args:
|
|
customer_id: Stripe customer ID
|
|
payment_method_id: New payment method ID
|
|
|
|
Returns:
|
|
Payment method update result
|
|
"""
|
|
try:
|
|
result = await retry_with_backoff(
|
|
lambda: self.stripe_client.update_payment_method(customer_id, payment_method_id),
|
|
max_retries=3,
|
|
exceptions=(Exception,)
|
|
)
|
|
|
|
logger.info("Payment method updated successfully",
|
|
customer_id=customer_id,
|
|
payment_method_id=payment_method_id)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to update payment method: {str(e)}, customer_id: {customer_id}",
|
|
exc_info=True)
|
|
raise PaymentServiceError(f"Payment method update failed: {str(e)}") from e
|
|
|
|
async def complete_subscription_after_setup_intent(
|
|
self,
|
|
setup_intent_id: str,
|
|
customer_id: Optional[str] = None,
|
|
plan_id: Optional[str] = None,
|
|
payment_method_id: Optional[str] = None,
|
|
trial_period_days: Optional[int] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Complete subscription after SetupIntent verification
|
|
|
|
Args:
|
|
setup_intent_id: SetupIntent ID
|
|
customer_id: Stripe customer ID (optional, retrieved from SetupIntent if not provided)
|
|
plan_id: Plan ID for subscription creation
|
|
payment_method_id: Payment method ID (optional, retrieved from SetupIntent if not provided)
|
|
trial_period_days: Optional trial period
|
|
|
|
Returns:
|
|
Dictionary with 'subscription' key containing subscription details
|
|
"""
|
|
try:
|
|
# Step 1: Verify the SetupIntent
|
|
verification_result = await retry_with_backoff(
|
|
lambda: self.stripe_client.verify_setup_intent_status(setup_intent_id),
|
|
max_retries=3,
|
|
exceptions=(SetupIntentError,)
|
|
)
|
|
|
|
if not verification_result.get('verified'):
|
|
raise PaymentServiceError(
|
|
f"SetupIntent verification failed: {verification_result.get('last_setup_error', 'Unknown error')}"
|
|
)
|
|
|
|
# Use values from SetupIntent if not provided
|
|
actual_customer_id = customer_id or verification_result.get('customer_id')
|
|
actual_payment_method_id = payment_method_id or verification_result.get('payment_method_id')
|
|
|
|
logger.info("SetupIntent verified, creating subscription",
|
|
setup_intent_id=setup_intent_id,
|
|
customer_id=actual_customer_id)
|
|
|
|
# Step 2: Create the subscription if plan_id is provided
|
|
if plan_id:
|
|
# Get the billing interval (default to monthly)
|
|
billing_interval = 'monthly'
|
|
price_id = self._get_stripe_price_id(plan_id, billing_interval)
|
|
|
|
subscription_result = await self.create_subscription_with_verified_payment(
|
|
customer_id=actual_customer_id,
|
|
price_id=price_id,
|
|
payment_method_id=actual_payment_method_id,
|
|
trial_period_days=trial_period_days
|
|
)
|
|
|
|
# Create a mock subscription object-like dict for compatibility
|
|
# The caller expects result['subscription'].id, result['subscription'].status, etc.
|
|
class SubscriptionResult:
|
|
def __init__(self, data: Dict[str, Any]):
|
|
self.id = data.get('subscription_id')
|
|
self.status = data.get('status')
|
|
self.current_period_start = data.get('current_period_start')
|
|
self.current_period_end = data.get('current_period_end')
|
|
self.customer = data.get('customer_id')
|
|
|
|
return {
|
|
'subscription': SubscriptionResult(subscription_result),
|
|
'verification': verification_result
|
|
}
|
|
else:
|
|
# No plan_id provided, just return verification result
|
|
return {
|
|
'verification': verification_result,
|
|
'setup_intent_id': setup_intent_id,
|
|
'customer_id': actual_customer_id,
|
|
'payment_method_id': actual_payment_method_id
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to complete subscription after SetupIntent: {str(e)}, setup_intent_id: {setup_intent_id}",
|
|
exc_info=True)
|
|
raise PaymentServiceError(f"Subscription completion failed: {str(e)}") from e
|
|
|
|
async def verify_setup_intent_status(
|
|
self,
|
|
setup_intent_id: str
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Verify SetupIntent status
|
|
|
|
Args:
|
|
setup_intent_id: SetupIntent ID to verify
|
|
|
|
Returns:
|
|
SetupIntent verification result
|
|
"""
|
|
try:
|
|
result = await retry_with_backoff(
|
|
lambda: self.stripe_client.verify_setup_intent_status(setup_intent_id),
|
|
max_retries=3,
|
|
exceptions=(SetupIntentError,)
|
|
)
|
|
|
|
logger.info("SetupIntent verified",
|
|
setup_intent_id=setup_intent_id,
|
|
status=result.get('status'))
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to verify SetupIntent: {str(e)}, setup_intent_id: {setup_intent_id}",
|
|
exc_info=True)
|
|
raise PaymentServiceError(f"SetupIntent verification failed: {str(e)}") from e
|
|
|
|
async def update_subscription_payment_method(
|
|
self,
|
|
subscription_id: str,
|
|
payment_method_id: str
|
|
) -> Any:
|
|
"""
|
|
Update an existing subscription with a new payment method
|
|
|
|
Args:
|
|
subscription_id: Stripe subscription ID
|
|
payment_method_id: New payment method ID
|
|
|
|
Returns:
|
|
Updated Stripe subscription object
|
|
"""
|
|
try:
|
|
logger.info("Updating subscription payment method",
|
|
subscription_id=subscription_id,
|
|
payment_method_id=payment_method_id)
|
|
|
|
# Update the subscription's default payment method
|
|
stripe_subscription = await retry_with_backoff(
|
|
lambda: self.stripe_client.update_subscription_payment_method(
|
|
subscription_id,
|
|
payment_method_id
|
|
),
|
|
max_retries=3,
|
|
exceptions=(SubscriptionUpdateFailed,)
|
|
)
|
|
|
|
logger.info("Subscription payment method updated successfully",
|
|
subscription_id=subscription_id,
|
|
status=stripe_subscription.status)
|
|
|
|
return stripe_subscription
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to update subscription payment method: {str(e)}, subscription_id: {subscription_id}",
|
|
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() |