Files
bakery-ia/services/tenant/app/services/payment_service.py
2026-01-16 20:25:45 +01:00

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()