Add subcription feature 2

This commit is contained in:
Urtzi Alfaro
2026-01-14 13:15:48 +01:00
parent 6ddf608d37
commit a4c3b7da3f
32 changed files with 4240 additions and 965 deletions

View File

@@ -38,6 +38,13 @@ class Subscription:
created_at: datetime
billing_cycle_anchor: Optional[datetime] = None
cancel_at_period_end: Optional[bool] = None
# 3DS Authentication fields
payment_intent_id: Optional[str] = None
payment_intent_status: Optional[str] = None
payment_intent_client_secret: Optional[str] = None
requires_action: Optional[bool] = None
trial_end: Optional[datetime] = None
billing_interval: Optional[str] = None
@dataclass

View File

@@ -15,6 +15,11 @@ from .payment_client import PaymentProvider, PaymentCustomer, PaymentMethod, Sub
logger = structlog.get_logger()
class PaymentVerificationError(Exception):
"""Exception raised when payment method verification fails"""
pass
class StripeProvider(PaymentProvider):
"""
Stripe implementation of the PaymentProvider interface
@@ -62,20 +67,23 @@ class StripeProvider(PaymentProvider):
email=customer_data.get('email'))
raise e
async def create_subscription(self, customer_id: str, plan_id: str, payment_method_id: str, trial_period_days: Optional[int] = None) -> Subscription:
async def create_subscription(self, customer_id: str, plan_id: str, payment_method_id: str, trial_period_days: Optional[int] = None) -> Dict[str, Any]:
"""
Create a subscription in Stripe with idempotency and enhanced error handling
Returns:
Dictionary containing subscription details and any required authentication actions
"""
try:
subscription_idempotency_key = f"create_subscription_{uuid.uuid4()}"
payment_method_idempotency_key = f"attach_pm_{uuid.uuid4()}"
customer_update_idempotency_key = f"update_customer_{uuid.uuid4()}"
logger.info("Creating Stripe subscription",
logger.info("Creating Stripe subscription",
customer_id=customer_id,
plan_id=plan_id,
payment_method_id=payment_method_id)
# Attach payment method to customer with idempotency and error handling
try:
stripe.PaymentMethod.attach(
@@ -94,7 +102,7 @@ class StripeProvider(PaymentProvider):
payment_method_id=payment_method_id)
else:
raise
# Set customer's default payment method with idempotency
stripe.Customer.modify(
customer_id,
@@ -103,10 +111,68 @@ class StripeProvider(PaymentProvider):
},
idempotency_key=customer_update_idempotency_key
)
logger.info("Customer default payment method updated",
logger.info("Customer default payment method updated",
customer_id=customer_id)
# Verify payment method before creating subscription (especially important for trial periods)
# This ensures the card is valid and can be charged after the trial
# Use SetupIntent for card verification without immediate payment
#
# CRITICAL FOR 3DS SUPPORT:
# We do NOT confirm the SetupIntent here because:
# 1. If 3DS is required, we need the frontend to handle the redirect
# 2. The frontend will confirm the SetupIntent with the return_url
# 3. After 3DS completion, frontend will call us again with the verified payment method
#
# This prevents creating subscriptions with unverified payment methods.
setup_intent_params = {
'customer': customer_id,
'payment_method': payment_method_id,
'usage': 'off_session', # Allow charging without customer presence after verification
'idempotency_key': f"verify_payment_{uuid.uuid4()}",
'expand': ['payment_method'],
'confirm': False # Explicitly don't confirm yet - frontend will handle 3DS
}
try:
# Create SetupIntent WITHOUT confirming
# Frontend will confirm with return_url if 3DS is needed
verification_intent = stripe.SetupIntent.create(**setup_intent_params)
logger.info("SetupIntent created for payment method verification",
setup_intent_id=verification_intent.id,
status=verification_intent.status,
payment_method_id=payment_method_id)
# ALWAYS return the SetupIntent for frontend to confirm
# Frontend will handle 3DS if needed, or confirm immediately if not
# This ensures proper 3DS flow for all cards
return {
'requires_action': True, # Frontend must always confirm
'action_type': 'setup_intent_confirmation',
'client_secret': verification_intent.client_secret,
'setup_intent_id': verification_intent.id,
'status': verification_intent.status,
'customer_id': customer_id,
'payment_method_id': payment_method_id,
'plan_id': plan_id,
'trial_period_days': trial_period_days,
'message': 'Payment method verification required. Frontend must confirm SetupIntent.'
}
except stripe.error.CardError as e:
logger.error("Payment method verification failed",
error=str(e),
code=e.code,
decline_code=e.decline_code)
raise PaymentVerificationError(f"Card verification failed: {e.user_message or str(e)}")
except Exception as e:
logger.error("Unexpected error during payment verification",
error=str(e))
raise PaymentVerificationError(f"Payment verification error: {str(e)}")
# Create subscription with trial period if specified
subscription_params = {
'customer': customer_id,
@@ -115,29 +181,28 @@ class StripeProvider(PaymentProvider):
'idempotency_key': subscription_idempotency_key,
'expand': ['latest_invoice.payment_intent']
}
if trial_period_days:
subscription_params['trial_period_days'] = trial_period_days
logger.info("Subscription includes trial period",
logger.info("Subscription includes trial period",
trial_period_days=trial_period_days)
stripe_subscription = stripe.Subscription.create(**subscription_params)
# Handle period dates for trial vs active subscriptions
# During trial: current_period_* fields are only in subscription items, not root
# After trial: current_period_* fields are at root level
if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data:
# For trial subscriptions, get period from first subscription item
first_item = stripe_subscription.items.data[0]
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
logger.info("Stripe trial subscription created successfully",
subscription_id=stripe_subscription.id,
status=stripe_subscription.status,
trial_end=stripe_subscription.trial_end,
current_period_end=current_period_end)
if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'items') and stripe_subscription.items:
# Access items properly - stripe_subscription.items is typically a list-like object
items_list = stripe_subscription.items.data if hasattr(stripe_subscription.items, 'data') else stripe_subscription.items
if items_list and len(items_list) > 0:
first_item = items_list[0] if isinstance(items_list, list) else items_list
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
logger.info("Stripe trial subscription created successfully",
subscription_id=stripe_subscription.id,
status=stripe_subscription.status,
trial_end=stripe_subscription.trial_end,
current_period_end=current_period_end)
else:
# For active subscriptions, get period from root level
current_period_start = stripe_subscription.current_period_start
current_period_end = stripe_subscription.current_period_end
logger.info("Stripe subscription created successfully",
@@ -145,15 +210,53 @@ class StripeProvider(PaymentProvider):
status=stripe_subscription.status,
current_period_end=current_period_end)
return Subscription(
# Check if payment requires action (3D Secure, SCA)
requires_action = False
client_secret = None
payment_intent_status = None
if stripe_subscription.latest_invoice:
if hasattr(stripe_subscription.latest_invoice, 'payment_intent') and stripe_subscription.latest_invoice.payment_intent:
payment_intent = stripe_subscription.latest_invoice.payment_intent
payment_intent_status = payment_intent.status
if payment_intent.status in ['requires_action', 'requires_source_action']:
requires_action = True
client_secret = payment_intent.client_secret
logger.info("Subscription payment requires authentication",
subscription_id=stripe_subscription.id,
payment_intent_id=payment_intent.id,
status=payment_intent.status)
# Calculate trial end if this is a trial subscription
trial_end_timestamp = None
if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'trial_end'):
trial_end_timestamp = stripe_subscription.trial_end
subscription_obj = Subscription(
id=stripe_subscription.id,
customer_id=stripe_subscription.customer,
plan_id=plan_id, # Using the price ID as plan_id
plan_id=plan_id,
status=stripe_subscription.status,
current_period_start=datetime.fromtimestamp(current_period_start),
current_period_end=datetime.fromtimestamp(current_period_end),
created_at=datetime.fromtimestamp(stripe_subscription.created)
created_at=datetime.fromtimestamp(stripe_subscription.created),
billing_cycle_anchor=datetime.fromtimestamp(stripe_subscription.billing_cycle_anchor) if hasattr(stripe_subscription, 'billing_cycle_anchor') else None,
cancel_at_period_end=stripe_subscription.cancel_at_period_end if hasattr(stripe_subscription, 'cancel_at_period_end') else None,
payment_intent_id=stripe_subscription.latest_invoice.payment_intent.id if (hasattr(stripe_subscription.latest_invoice, 'payment_intent') and stripe_subscription.latest_invoice.payment_intent) else None,
payment_intent_status=payment_intent_status,
payment_intent_client_secret=client_secret,
requires_action=requires_action,
trial_end=datetime.fromtimestamp(trial_end_timestamp) if trial_end_timestamp else None,
billing_interval="monthly" # Default, should be extracted from plan
)
return {
'subscription': subscription_obj,
'requires_action': requires_action,
'client_secret': client_secret,
'payment_intent_status': payment_intent_status
}
except stripe.error.CardError as e:
logger.error("Card error during subscription creation",
error=str(e),
@@ -175,9 +278,134 @@ class StripeProvider(PaymentProvider):
plan_id=plan_id)
raise e
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod:
async def complete_subscription_after_setup_intent(
self,
setup_intent_id: str,
customer_id: str,
plan_id: str,
payment_method_id: str,
trial_period_days: Optional[int] = None
) -> Dict[str, Any]:
"""
Complete subscription creation after SetupIntent has been confirmed by frontend
This method should be called after the frontend successfully confirms a SetupIntent
(with or without 3DS). It verifies the SetupIntent is in 'succeeded' status and
then creates the subscription with the now-verified payment method.
Args:
setup_intent_id: The SetupIntent ID that was confirmed
customer_id: Stripe customer ID
plan_id: Subscription plan/price ID
payment_method_id: Payment method ID (should match SetupIntent)
trial_period_days: Optional trial period
Returns:
Dictionary containing subscription details
"""
try:
subscription_idempotency_key = f"complete_subscription_{uuid.uuid4()}"
logger.info("Completing subscription after SetupIntent confirmation",
setup_intent_id=setup_intent_id,
customer_id=customer_id,
plan_id=plan_id)
# Verify the SetupIntent is in succeeded status
setup_intent = stripe.SetupIntent.retrieve(setup_intent_id)
if setup_intent.status != 'succeeded':
logger.error("SetupIntent not in succeeded status",
setup_intent_id=setup_intent_id,
status=setup_intent.status)
raise PaymentVerificationError(
f"SetupIntent must be in 'succeeded' status. Current status: {setup_intent.status}"
)
logger.info("SetupIntent verified as succeeded, creating subscription",
setup_intent_id=setup_intent_id)
# Payment method is already attached and verified via SetupIntent
# Now create the subscription
subscription_params = {
'customer': customer_id,
'items': [{'price': plan_id}],
'default_payment_method': payment_method_id,
'idempotency_key': subscription_idempotency_key,
'expand': ['latest_invoice.payment_intent']
}
if trial_period_days:
subscription_params['trial_period_days'] = trial_period_days
logger.info("Subscription includes trial period",
trial_period_days=trial_period_days)
stripe_subscription = stripe.Subscription.create(**subscription_params)
# Handle period dates for trial vs active subscriptions
if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'items') and stripe_subscription.items:
items_list = stripe_subscription.items.data if hasattr(stripe_subscription.items, 'data') else stripe_subscription.items
if items_list and len(items_list) > 0:
first_item = items_list[0] if isinstance(items_list, list) else items_list
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
logger.info("Stripe trial subscription created after SetupIntent",
subscription_id=stripe_subscription.id,
status=stripe_subscription.status,
trial_end=stripe_subscription.trial_end,
current_period_end=current_period_end)
else:
current_period_start = stripe_subscription.current_period_start
current_period_end = stripe_subscription.current_period_end
logger.info("Stripe subscription created after SetupIntent",
subscription_id=stripe_subscription.id,
status=stripe_subscription.status,
current_period_end=current_period_end)
# Calculate trial end if this is a trial subscription
trial_end_timestamp = None
if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'trial_end'):
trial_end_timestamp = stripe_subscription.trial_end
subscription_obj = Subscription(
id=stripe_subscription.id,
customer_id=stripe_subscription.customer,
plan_id=plan_id,
status=stripe_subscription.status,
current_period_start=datetime.fromtimestamp(current_period_start),
current_period_end=datetime.fromtimestamp(current_period_end),
created_at=datetime.fromtimestamp(stripe_subscription.created),
billing_cycle_anchor=datetime.fromtimestamp(stripe_subscription.billing_cycle_anchor) if hasattr(stripe_subscription, 'billing_cycle_anchor') else None,
cancel_at_period_end=stripe_subscription.cancel_at_period_end if hasattr(stripe_subscription, 'cancel_at_period_end') else None,
payment_intent_id=stripe_subscription.latest_invoice.payment_intent.id if (hasattr(stripe_subscription.latest_invoice, 'payment_intent') and stripe_subscription.latest_invoice.payment_intent) else None,
payment_intent_status=None,
payment_intent_client_secret=None,
requires_action=False,
trial_end=datetime.fromtimestamp(trial_end_timestamp) if trial_end_timestamp else None,
billing_interval="monthly"
)
return {
'subscription': subscription_obj,
'requires_action': False,
'client_secret': None,
'payment_intent_status': None,
'setup_intent_id': setup_intent_id
}
except stripe.error.StripeError as e:
logger.error("Failed to complete subscription after SetupIntent",
error=str(e),
setup_intent_id=setup_intent_id,
customer_id=customer_id)
raise e
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> Dict[str, Any]:
"""
Update the payment method for a customer in Stripe
Returns:
Dictionary containing payment method details and any required authentication actions
"""
try:
# Attach payment method to customer with error handling
@@ -199,23 +427,58 @@ class StripeProvider(PaymentProvider):
raise
# Set as default payment method
stripe.Customer.modify(
customer = stripe.Customer.modify(
customer_id,
invoice_settings={
'default_payment_method': payment_method_id
}
)
stripe_payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
return PaymentMethod(
id=stripe_payment_method.id,
type=stripe_payment_method.type,
brand=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('brand'),
last4=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('last4'),
exp_month=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('exp_month'),
exp_year=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('exp_year'),
)
# Get any active subscriptions that might need payment confirmation
subscriptions = stripe.Subscription.list(customer=customer_id, status='active', limit=1)
requires_action = False
client_secret = None
payment_intent_status = None
# Check if there's a subscription with pending payment that requires action
if subscriptions.data:
subscription = subscriptions.data[0]
if subscription.latest_invoice:
invoice = stripe.Invoice.retrieve(
subscription.latest_invoice,
expand=['payment_intent']
)
if invoice.payment_intent:
payment_intent = invoice.payment_intent
payment_intent_status = payment_intent.status
if payment_intent.status in ['requires_action', 'requires_source_action']:
requires_action = True
client_secret = payment_intent.client_secret
logger.info("Payment requires authentication",
customer_id=customer_id,
payment_intent_id=payment_intent.id,
status=payment_intent.status)
payment_method_details = getattr(stripe_payment_method, stripe_payment_method.type, {})
return {
'payment_method': {
'id': stripe_payment_method.id,
'type': stripe_payment_method.type,
'brand': payment_method_details.get('brand') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'brand', None),
'last4': payment_method_details.get('last4') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'last4', None),
'exp_month': payment_method_details.get('exp_month') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'exp_month', None),
'exp_year': payment_method_details.get('exp_year') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'exp_year', None),
},
'requires_action': requires_action,
'client_secret': client_secret,
'payment_intent_status': payment_intent_status
}
except stripe.error.StripeError as e:
logger.error("Failed to update Stripe payment method", error=str(e))
raise e
@@ -253,10 +516,12 @@ class StripeProvider(PaymentProvider):
subscription_id=subscription_id)
# Handle period dates for trial vs active subscriptions
if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data:
first_item = stripe_subscription.items.data[0]
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'items') and stripe_subscription.items:
items_list = stripe_subscription.items.data if hasattr(stripe_subscription.items, 'data') else stripe_subscription.items
if items_list and len(items_list) > 0:
first_item = items_list[0] if isinstance(items_list, list) else items_list
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
else:
current_period_start = stripe_subscription.current_period_start
current_period_end = stripe_subscription.current_period_end
@@ -316,17 +581,22 @@ class StripeProvider(PaymentProvider):
# Get the actual plan ID from the subscription items
plan_id = subscription_id # Default fallback
if stripe_subscription.items and stripe_subscription.items.data:
plan_id = stripe_subscription.items.data[0].price.id
if hasattr(stripe_subscription, 'items') and stripe_subscription.items:
items_list = stripe_subscription.items.data if hasattr(stripe_subscription.items, 'data') else stripe_subscription.items
if items_list and len(items_list) > 0:
first_item = items_list[0] if isinstance(items_list, list) else items_list
plan_id = first_item.price.id
# Handle period dates for trial vs active subscriptions
# During trial: current_period_* fields are only in subscription items, not root
# After trial: current_period_* fields are at root level
if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data:
# For trial subscriptions, get period from first subscription item
first_item = stripe_subscription.items.data[0]
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
if stripe_subscription.status == 'trialing' and hasattr(stripe_subscription, 'items') and stripe_subscription.items:
items_list = stripe_subscription.items.data if hasattr(stripe_subscription.items, 'data') else stripe_subscription.items
if items_list and len(items_list) > 0:
# For trial subscriptions, get period from first subscription item
first_item = items_list[0] if isinstance(items_list, list) else items_list
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
else:
# For active subscriptions, get period from root level
current_period_start = stripe_subscription.current_period_start
@@ -381,9 +651,10 @@ class StripeProvider(PaymentProvider):
current_subscription = stripe.Subscription.retrieve(subscription_id)
# Build update parameters
items_list = current_subscription.items.data if hasattr(current_subscription.items, 'data') else current_subscription.items
update_params = {
'items': [{
'id': current_subscription.items.data[0].id,
'id': items_list[0].id if isinstance(items_list, list) else items_list.id,
'price': new_price_id,
}],
'proration_behavior': proration_behavior,
@@ -411,12 +682,17 @@ class StripeProvider(PaymentProvider):
# Get the actual plan ID from the subscription items
plan_id = new_price_id
if updated_subscription.items and updated_subscription.items.data:
plan_id = updated_subscription.items.data[0].price.id
if hasattr(updated_subscription, 'items') and updated_subscription.items:
items_list = updated_subscription.items.data if hasattr(updated_subscription.items, 'data') else updated_subscription.items
if items_list and len(items_list) > 0:
first_item = items_list[0] if isinstance(items_list, list) else items_list
plan_id = first_item.price.id
# Handle period dates for trial vs active subscriptions
if updated_subscription.status == 'trialing' and updated_subscription.items and updated_subscription.items.data:
first_item = updated_subscription.items.data[0]
if updated_subscription.status == 'trialing' and hasattr(updated_subscription, 'items') and updated_subscription.items:
items_list = updated_subscription.items.data if hasattr(updated_subscription.items, 'data') else updated_subscription.items
if items_list and len(items_list) > 0:
first_item = items_list[0] if isinstance(items_list, list) else items_list
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
else:
@@ -463,10 +739,12 @@ class StripeProvider(PaymentProvider):
logger.info("Calculating proration for subscription change",
subscription_id=subscription_id,
new_price_id=new_price_id)
# Get current subscription
current_subscription = stripe.Subscription.retrieve(subscription_id)
current_price_id = current_subscription.items.data[0].price.id
items_list = current_subscription.items.data if hasattr(current_subscription.items, 'data') else current_subscription.items
first_item = items_list[0] if isinstance(items_list, list) else items_list
current_price_id = first_item.price.id
# Get current and new prices
current_price = stripe.Price.retrieve(current_price_id)
@@ -560,10 +838,12 @@ class StripeProvider(PaymentProvider):
logger.info("Changing billing cycle for subscription",
subscription_id=subscription_id,
new_billing_cycle=new_billing_cycle)
# Get current subscription
current_subscription = stripe.Subscription.retrieve(subscription_id)
current_price_id = current_subscription.items.data[0].price.id
items_list = current_subscription.items.data if hasattr(current_subscription.items, 'data') else current_subscription.items
first_item = items_list[0] if isinstance(items_list, list) else items_list
current_price_id = first_item.price.id
# Get current price to determine the plan
current_price = stripe.Price.retrieve(current_price_id)
@@ -643,7 +923,7 @@ class StripeProvider(PaymentProvider):
payment_method=payment_method_id,
confirm=True
)
return {
'id': payment_intent.id,
'client_secret': payment_intent.client_secret,
@@ -652,3 +932,90 @@ class StripeProvider(PaymentProvider):
except stripe.error.StripeError as e:
logger.error("Failed to create Stripe payment intent", error=str(e))
raise e
async def get_customer_payment_method(self, customer_id: str) -> Optional[PaymentMethod]:
"""
Get the default payment method for a customer from Stripe
Args:
customer_id: Stripe customer ID
Returns:
PaymentMethod object or None if no payment method exists
"""
try:
logger.info("Retrieving customer payment method", customer_id=customer_id)
# Retrieve the customer to get default payment method
stripe_customer = stripe.Customer.retrieve(customer_id)
# Get the default payment method ID
default_payment_method_id = None
if stripe_customer.invoice_settings and stripe_customer.invoice_settings.default_payment_method:
default_payment_method_id = stripe_customer.invoice_settings.default_payment_method
elif stripe_customer.default_source:
default_payment_method_id = stripe_customer.default_source
if not default_payment_method_id:
logger.info("No default payment method found for customer", customer_id=customer_id)
return None
# Retrieve the payment method details
stripe_payment_method = stripe.PaymentMethod.retrieve(default_payment_method_id)
# Extract payment method details based on type
payment_method_details = getattr(stripe_payment_method, stripe_payment_method.type, {})
logger.info("Customer payment method retrieved successfully",
customer_id=customer_id,
payment_method_id=stripe_payment_method.id,
payment_method_type=stripe_payment_method.type)
return PaymentMethod(
id=stripe_payment_method.id,
type=stripe_payment_method.type,
brand=payment_method_details.get('brand') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'brand', None),
last4=payment_method_details.get('last4') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'last4', None),
exp_month=payment_method_details.get('exp_month') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'exp_month', None),
exp_year=payment_method_details.get('exp_year') if isinstance(payment_method_details, dict) else getattr(payment_method_details, 'exp_year', None),
)
except stripe.error.StripeError as e:
logger.error("Failed to retrieve customer payment method",
error=str(e),
customer_id=customer_id)
raise e
async def get_setup_intent(self, setup_intent_id: str) -> stripe.SetupIntent:
"""
Retrieve a SetupIntent from Stripe
Args:
setup_intent_id: SetupIntent ID to retrieve
Returns:
stripe.SetupIntent object
Raises:
stripe.error.StripeError: If retrieval fails
"""
try:
logger.info("Retrieving SetupIntent from Stripe",
setup_intent_id=setup_intent_id)
# Retrieve SetupIntent from Stripe
setup_intent = stripe.SetupIntent.retrieve(setup_intent_id)
logger.info("SetupIntent retrieved successfully",
setup_intent_id=setup_intent.id,
status=setup_intent.status,
customer_id=setup_intent.customer)
return setup_intent
except stripe.error.StripeError as e:
logger.error("Failed to retrieve SetupIntent from Stripe",
error=str(e),
setup_intent_id=setup_intent_id,
error_type=type(e).__name__)
raise e

View File

@@ -622,6 +622,184 @@ class TenantServiceClient(BaseServiceClient):
return None
async def create_payment_customer(
self,
user_data: Dict[str, Any],
payment_method_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Create payment customer (supports pre-user-creation flow)
This method creates a payment customer without requiring a user_id,
supporting the secure architecture where users are only created after
payment verification.
Args:
user_data: User data (email, full_name, etc.)
payment_method_id: Optional payment method ID
Returns:
Dictionary with payment customer creation result
"""
try:
logger.info("Creating payment customer via tenant service",
email=user_data.get('email'),
payment_method_id=payment_method_id)
# Call tenant service endpoint
result = await self.post(
"/payment-customers/create",
{
"user_data": user_data,
"payment_method_id": payment_method_id
}
)
if result and result.get("success"):
logger.info("Payment customer created successfully via tenant service",
email=user_data.get('email'),
payment_customer_id=result.get('payment_customer_id'))
return result
else:
logger.error("Payment customer creation failed via tenant service",
email=user_data.get('email'),
error=result.get('detail') if result else 'No detail provided')
raise Exception("Payment customer creation failed: " +
(result.get('detail') if result else 'Unknown error'))
except Exception as e:
logger.error("Failed to create payment customer via tenant service",
email=user_data.get('email'),
error=str(e))
raise
async def create_registration_payment_setup(
self,
user_data: Dict[str, Any]
) -> Dict[str, Any]:
"""
Create registration payment setup via tenant service orchestration
This method calls the tenant service's orchestration endpoint to create
payment customer and SetupIntent in a coordinated workflow.
Args:
user_data: User data including email, full_name, payment_method_id, etc.
Returns:
Dictionary with payment setup results including SetupIntent if required
"""
try:
logger.info("Creating registration payment setup via tenant service orchestration",
email=user_data.get('email'),
payment_method_id=user_data.get('payment_method_id'))
# Call tenant service orchestration endpoint
result = await self.post(
"/payment-customers/create",
user_data
)
if result and result.get("success"):
logger.info("Registration payment setup completed via tenant service orchestration",
email=user_data.get('email'),
requires_action=result.get('requires_action'))
return result
else:
logger.error("Registration payment setup failed via tenant service orchestration",
email=user_data.get('email'),
error=result.get('detail') if result else 'No detail provided')
raise Exception("Registration payment setup failed: " +
(result.get('detail') if result else 'Unknown error'))
except Exception as e:
logger.error("Failed to create registration payment setup via tenant service orchestration",
email=user_data.get('email'),
error=str(e))
raise
async def verify_setup_intent_for_registration(
self,
setup_intent_id: str
) -> Dict[str, Any]:
"""
Verify SetupIntent status via tenant service orchestration
This method calls the tenant service's orchestration endpoint to verify
SetupIntent status before proceeding with user creation.
Args:
setup_intent_id: SetupIntent ID to verify
Returns:
Dictionary with SetupIntent verification result
"""
try:
logger.info("Verifying SetupIntent via tenant service orchestration",
setup_intent_id=setup_intent_id)
# Call tenant service orchestration endpoint
result = await self.get(
f"/setup-intents/{setup_intent_id}/verify"
)
if result:
logger.info("SetupIntent verification result from tenant service orchestration",
setup_intent_id=setup_intent_id,
status=result.get('status'))
return result
else:
logger.error("SetupIntent verification failed via tenant service orchestration",
setup_intent_id=setup_intent_id,
error='No result returned')
raise Exception("SetupIntent verification failed: No result returned")
except Exception as e:
logger.error("Failed to verify SetupIntent via tenant service orchestration",
setup_intent_id=setup_intent_id,
error=str(e))
raise
async def verify_setup_intent(
self,
setup_intent_id: str
) -> Dict[str, Any]:
"""
Verify SetupIntent status with payment provider
Args:
setup_intent_id: SetupIntent ID to verify
Returns:
Dictionary with SetupIntent verification result
"""
try:
logger.info("Verifying SetupIntent via tenant service",
setup_intent_id=setup_intent_id)
# Call tenant service endpoint
result = await self.get(
f"/setup-intents/{setup_intent_id}/verify"
)
if result:
logger.info("SetupIntent verification result from tenant service",
setup_intent_id=setup_intent_id,
status=result.get('status'))
return result
else:
logger.error("SetupIntent verification failed via tenant service",
setup_intent_id=setup_intent_id,
error='No result returned')
raise Exception("SetupIntent verification failed: No result returned")
except Exception as e:
logger.error("Failed to verify SetupIntent via tenant service",
setup_intent_id=setup_intent_id,
error=str(e))
raise
# Factory function for dependency injection
def create_tenant_client(config: BaseServiceSettings) -> TenantServiceClient:
"""Create tenant service client instance"""