Add subcription feature 4
This commit is contained in:
@@ -15,7 +15,9 @@ from shared.exceptions.payment_exceptions import (
|
||||
PaymentVerificationError,
|
||||
SubscriptionCreationFailed,
|
||||
SetupIntentError,
|
||||
SubscriptionUpdateFailed
|
||||
SubscriptionUpdateFailed,
|
||||
PaymentMethodError,
|
||||
CustomerUpdateFailed
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
@@ -42,38 +44,75 @@ class StripeClient(PaymentProvider):
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Atomic: Create SetupIntent for payment method verification
|
||||
This is the FIRST step in secure registration flow
|
||||
|
||||
Create standalone SetupIntent for payment verification during registration.
|
||||
|
||||
This is the ONLY step that happens before 3DS verification completes.
|
||||
NO subscription is created here - subscription is created AFTER verification.
|
||||
|
||||
Flow:
|
||||
1. Frontend collects payment method
|
||||
2. Backend creates customer + SetupIntent (this method)
|
||||
3. Frontend confirms SetupIntent (handles 3DS if needed)
|
||||
4. Backend creates subscription AFTER SetupIntent succeeds
|
||||
|
||||
Args:
|
||||
customer_id: Stripe customer ID
|
||||
payment_method_id: Payment method ID to verify
|
||||
metadata: Additional metadata for tracking
|
||||
|
||||
|
||||
Returns:
|
||||
SetupIntent result with verification requirements
|
||||
|
||||
SetupIntent result for frontend confirmation
|
||||
|
||||
Raises:
|
||||
SetupIntentError: If SetupIntent creation fails
|
||||
"""
|
||||
try:
|
||||
# First attach payment method to customer
|
||||
try:
|
||||
stripe.PaymentMethod.attach(
|
||||
payment_method_id,
|
||||
customer=customer_id
|
||||
)
|
||||
logger.info(
|
||||
"Payment method attached to customer",
|
||||
extra={"payment_method_id": payment_method_id, "customer_id": customer_id}
|
||||
)
|
||||
except stripe.error.InvalidRequestError as e:
|
||||
# Payment method might already be attached
|
||||
if "already been attached" not in str(e):
|
||||
raise
|
||||
logger.info(
|
||||
"Payment method already attached to customer",
|
||||
extra={"payment_method_id": payment_method_id, "customer_id": customer_id}
|
||||
)
|
||||
|
||||
# Set as default payment method on customer
|
||||
stripe.Customer.modify(
|
||||
customer_id,
|
||||
invoice_settings={'default_payment_method': payment_method_id}
|
||||
)
|
||||
|
||||
# Create SetupIntent for verification
|
||||
setup_intent_params = {
|
||||
'customer': customer_id,
|
||||
'payment_method': payment_method_id,
|
||||
'usage': 'off_session',
|
||||
'confirm': False, # Frontend must confirm to handle 3DS
|
||||
'idempotency_key': f"setup_intent_{uuid.uuid4()}",
|
||||
'usage': 'off_session', # For future recurring payments
|
||||
'confirm': True, # Confirm immediately - this triggers 3DS check
|
||||
'idempotency_key': f"setup_intent_reg_{uuid.uuid4()}",
|
||||
'metadata': metadata or {
|
||||
'purpose': 'registration_payment_verification',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
'timestamp': datetime.now(timezone.utc).isoformat()
|
||||
},
|
||||
'automatic_payment_methods': {
|
||||
'enabled': True,
|
||||
'allow_redirects': 'never'
|
||||
}
|
||||
}
|
||||
|
||||
# Create SetupIntent without confirmation
|
||||
|
||||
setup_intent = stripe.SetupIntent.create(**setup_intent_params)
|
||||
|
||||
|
||||
logger.info(
|
||||
"SetupIntent created for payment verification",
|
||||
"SetupIntent created for verification",
|
||||
extra={
|
||||
"setup_intent_id": setup_intent.id,
|
||||
"status": setup_intent.status,
|
||||
@@ -81,25 +120,24 @@ class StripeClient(PaymentProvider):
|
||||
"payment_method_id": payment_method_id
|
||||
}
|
||||
)
|
||||
|
||||
# Always return SetupIntent for frontend confirmation
|
||||
# Frontend will handle 3DS if required
|
||||
# Note: With confirm=False, the SetupIntent will have status 'requires_confirmation'
|
||||
# The actual 3DS requirement is only determined after frontend confirmation
|
||||
|
||||
# Check if 3DS is required
|
||||
requires_action = setup_intent.status in ['requires_action', 'requires_confirmation']
|
||||
|
||||
return {
|
||||
'setup_intent_id': setup_intent.id,
|
||||
'client_secret': setup_intent.client_secret,
|
||||
'status': setup_intent.status,
|
||||
'requires_action': True, # Always require frontend confirmation for 3DS support
|
||||
'requires_action': requires_action,
|
||||
'customer_id': customer_id,
|
||||
'payment_method_id': payment_method_id,
|
||||
'created': setup_intent.created,
|
||||
'metadata': setup_intent.metadata
|
||||
'metadata': dict(setup_intent.metadata) if setup_intent.metadata else {}
|
||||
}
|
||||
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error(
|
||||
"Stripe SetupIntent creation failed",
|
||||
"SetupIntent creation for verification failed",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
@@ -111,7 +149,7 @@ class StripeClient(PaymentProvider):
|
||||
raise SetupIntentError(f"SetupIntent creation failed: {str(e)}") from e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Unexpected error creating SetupIntent",
|
||||
"Unexpected error creating SetupIntent for verification",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"customer_id": customer_id,
|
||||
@@ -120,7 +158,226 @@ class StripeClient(PaymentProvider):
|
||||
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_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.
|
||||
|
||||
This is the SECOND step - only called after frontend confirms SetupIntent.
|
||||
The payment method is already verified at this point.
|
||||
|
||||
STRIPE BEST PRACTICES FOR TRIALS:
|
||||
- For trial subscriptions: attach payment method to CUSTOMER (not subscription)
|
||||
- Use off_session=True for future merchant-initiated charges
|
||||
- Trial subscriptions generate $0 invoices initially
|
||||
- Payment method is charged automatically when trial ends
|
||||
|
||||
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:
|
||||
has_trial = trial_period_days and trial_period_days > 0
|
||||
|
||||
# Build base metadata
|
||||
base_metadata = metadata or {}
|
||||
base_metadata.update({
|
||||
'purpose': 'registration_subscription',
|
||||
'created_after_verification': 'true',
|
||||
'timestamp': datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
|
||||
# STRIPE BEST PRACTICE: For trial subscriptions, attach payment method
|
||||
# to CUSTOMER (not subscription) to avoid immediate charges
|
||||
if has_trial:
|
||||
# Set payment method as customer's default (already done in SetupIntent,
|
||||
# but ensure it's set for subscription billing)
|
||||
stripe.Customer.modify(
|
||||
customer_id,
|
||||
invoice_settings={'default_payment_method': payment_method_id}
|
||||
)
|
||||
|
||||
subscription_params = {
|
||||
'customer': customer_id,
|
||||
'items': [{'price': price_id}],
|
||||
'trial_period_days': trial_period_days,
|
||||
'off_session': True, # Future charges are merchant-initiated
|
||||
'idempotency_key': f"sub_trial_{uuid.uuid4()}",
|
||||
'payment_settings': {
|
||||
'payment_method_options': {
|
||||
'card': {
|
||||
'request_three_d_secure': 'automatic'
|
||||
}
|
||||
},
|
||||
'save_default_payment_method': 'on_subscription'
|
||||
},
|
||||
'metadata': {
|
||||
**base_metadata,
|
||||
'trial_subscription': 'true',
|
||||
'trial_period_days': str(trial_period_days),
|
||||
'payment_strategy': 'customer_default_method'
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Creating TRIAL subscription (payment method on customer)",
|
||||
extra={
|
||||
"customer_id": customer_id,
|
||||
"price_id": price_id,
|
||||
"trial_period_days": trial_period_days
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Non-trial: attach payment method directly to subscription
|
||||
subscription_params = {
|
||||
'customer': customer_id,
|
||||
'items': [{'price': price_id}],
|
||||
'default_payment_method': payment_method_id,
|
||||
'idempotency_key': f"sub_immediate_{uuid.uuid4()}",
|
||||
'payment_settings': {
|
||||
'payment_method_options': {
|
||||
'card': {
|
||||
'request_three_d_secure': 'automatic'
|
||||
}
|
||||
},
|
||||
'save_default_payment_method': 'on_subscription'
|
||||
},
|
||||
'metadata': {
|
||||
**base_metadata,
|
||||
'trial_subscription': 'false',
|
||||
'payment_strategy': 'subscription_default_method'
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Creating NON-TRIAL subscription (payment method on subscription)",
|
||||
extra={
|
||||
"customer_id": customer_id,
|
||||
"price_id": price_id
|
||||
}
|
||||
)
|
||||
|
||||
# Create subscription
|
||||
subscription = stripe.Subscription.create(**subscription_params)
|
||||
|
||||
# Extract timestamps
|
||||
current_period_start = self._extract_timestamp(
|
||||
getattr(subscription, 'current_period_start', None)
|
||||
)
|
||||
current_period_end = self._extract_timestamp(
|
||||
getattr(subscription, 'current_period_end', None)
|
||||
)
|
||||
|
||||
# Verify trial was set correctly for trial subscriptions
|
||||
if has_trial:
|
||||
if subscription.status != 'trialing':
|
||||
logger.warning(
|
||||
"Trial subscription created but status is not 'trialing'",
|
||||
extra={
|
||||
"subscription_id": subscription.id,
|
||||
"status": subscription.status,
|
||||
"trial_period_days": trial_period_days,
|
||||
"trial_end": getattr(subscription, 'trial_end', None)
|
||||
}
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Trial subscription created successfully with $0 initial invoice",
|
||||
extra={
|
||||
"subscription_id": subscription.id,
|
||||
"status": subscription.status,
|
||||
"trial_period_days": trial_period_days,
|
||||
"trial_end": getattr(subscription, 'trial_end', None)
|
||||
}
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Subscription created successfully",
|
||||
extra={
|
||||
"subscription_id": subscription.id,
|
||||
"customer_id": customer_id,
|
||||
"status": subscription.status
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
'subscription_id': subscription.id,
|
||||
'customer_id': customer_id,
|
||||
'status': subscription.status,
|
||||
'current_period_start': current_period_start,
|
||||
'current_period_end': current_period_end,
|
||||
'trial_period_days': trial_period_days,
|
||||
'trial_end': getattr(subscription, 'trial_end', None),
|
||||
'created': getattr(subscription, 'created', None),
|
||||
'metadata': dict(subscription.metadata) if subscription.metadata else {}
|
||||
}
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error(
|
||||
"Subscription creation after verification failed",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"customer_id": customer_id,
|
||||
"price_id": price_id
|
||||
},
|
||||
exc_info=True
|
||||
)
|
||||
raise SubscriptionCreationFailed(f"Subscription creation failed: {str(e)}") from e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Unexpected error creating subscription after verification",
|
||||
extra={
|
||||
"error": 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 verify_setup_intent_status(
|
||||
self,
|
||||
setup_intent_id: str
|
||||
@@ -159,7 +416,8 @@ class StripeClient(PaymentProvider):
|
||||
'payment_method_id': setup_intent.payment_method,
|
||||
'verified': True,
|
||||
'requires_action': False,
|
||||
'last_setup_error': setup_intent.last_setup_error
|
||||
'last_setup_error': setup_intent.last_setup_error,
|
||||
'metadata': dict(setup_intent.metadata) if setup_intent.metadata else {}
|
||||
}
|
||||
elif setup_intent.status == 'requires_action':
|
||||
return {
|
||||
@@ -1347,6 +1605,102 @@ class StripeClient(PaymentProvider):
|
||||
)
|
||||
raise SubscriptionUpdateFailed(f"Stripe API failed: {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
|
||||
|
||||
Raises:
|
||||
PaymentMethodError: If the attachment fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Attaching payment method to customer in Stripe",
|
||||
customer_id=customer_id,
|
||||
payment_method_id=payment_method_id)
|
||||
|
||||
# Attach payment method to customer
|
||||
payment_method = stripe.PaymentMethod.attach(
|
||||
payment_method_id,
|
||||
customer=customer_id
|
||||
)
|
||||
|
||||
logger.info("Payment method attached to customer in Stripe",
|
||||
customer_id=customer_id,
|
||||
payment_method_id=payment_method.id)
|
||||
|
||||
return payment_method
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to attach payment method to customer in Stripe",
|
||||
extra={
|
||||
"customer_id": customer_id,
|
||||
"payment_method_id": payment_method_id,
|
||||
"error": str(e)
|
||||
},
|
||||
exc_info=True
|
||||
)
|
||||
raise PaymentMethodError(f"Stripe API failed: {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
|
||||
|
||||
Raises:
|
||||
CustomerUpdateFailed: If the update fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Setting default payment method for customer in Stripe",
|
||||
customer_id=customer_id,
|
||||
payment_method_id=payment_method_id)
|
||||
|
||||
# Set default payment method on customer
|
||||
customer = stripe.Customer.modify(
|
||||
customer_id,
|
||||
invoice_settings={
|
||||
'default_payment_method': payment_method_id
|
||||
}
|
||||
)
|
||||
|
||||
logger.info("Default payment method set for customer in Stripe",
|
||||
customer_id=customer.id,
|
||||
payment_method_id=payment_method_id)
|
||||
|
||||
return customer
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to set default payment method for customer in Stripe",
|
||||
extra={
|
||||
"customer_id": customer_id,
|
||||
"payment_method_id": payment_method_id,
|
||||
"error": str(e)
|
||||
},
|
||||
exc_info=True
|
||||
)
|
||||
raise CustomerUpdateFailed(f"Stripe API failed: {str(e)}") from e
|
||||
|
||||
|
||||
# Singleton instance for dependency injection
|
||||
stripe_client = StripeClient()
|
||||
@@ -67,4 +67,14 @@ class SubscriptionUpdateFailed(PaymentException):
|
||||
class PaymentServiceError(PaymentException):
|
||||
"""General payment service error"""
|
||||
def __init__(self, message: str = "Payment service error"):
|
||||
super().__init__(message)
|
||||
|
||||
class PaymentMethodError(PaymentException):
|
||||
"""Exception raised when payment method operations fail"""
|
||||
def __init__(self, message: str = "Payment method operation failed"):
|
||||
super().__init__(message)
|
||||
|
||||
class CustomerUpdateFailed(PaymentException):
|
||||
"""Exception raised when customer update operations fail"""
|
||||
def __init__(self, message: str = "Customer update failed"):
|
||||
super().__init__(message)
|
||||
Reference in New Issue
Block a user