Files
bakery-ia/shared/clients/stripe_client.py

1754 lines
70 KiB
Python
Raw Normal View History

2025-09-25 14:30:47 +02:00
"""
2026-01-15 20:45:49 +01:00
Atomic Stripe Client with proper 3DS/3DS2 support
Implements SetupIntent-first architecture for secure payment flows
Implements PaymentProvider interface for easy SDK swapping
2025-09-25 14:30:47 +02:00
"""
import stripe
2026-01-11 07:50:34 +01:00
import uuid
2026-01-15 20:45:49 +01:00
import logging
2025-09-25 14:30:47 +02:00
from typing import Dict, Any, Optional
2026-01-15 20:45:49 +01:00
from datetime import datetime, timezone
from shared.config.base import BaseServiceSettings
from shared.clients.payment_provider import PaymentProvider
from shared.exceptions.payment_exceptions import (
PaymentVerificationError,
SubscriptionCreationFailed,
SetupIntentError,
2026-01-15 22:06:36 +01:00
SubscriptionUpdateFailed,
PaymentMethodError,
CustomerUpdateFailed
2026-01-15 20:45:49 +01:00
)
# Configure logging
logger = logging.getLogger(__name__)
class StripeClient(PaymentProvider):
2025-09-25 14:30:47 +02:00
"""
2026-01-15 20:45:49 +01:00
Atomic Stripe operations with proper 3DS/3DS2 support
2025-09-25 14:30:47 +02:00
"""
2026-01-15 20:45:49 +01:00
def __init__(self):
"""Initialize Stripe client with configuration"""
settings = BaseServiceSettings()
stripe.api_key = settings.STRIPE_SECRET_KEY
# Let the SDK use its default pinned API version (2025-12-15.clover for v14.1.0)
# Only override if explicitly set in environment
if settings.STRIPE_API_VERSION:
stripe.api_version = settings.STRIPE_API_VERSION
async def create_setup_intent_for_verification(
self,
customer_id: str,
payment_method_id: str,
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
2025-09-25 14:30:47 +02:00
"""
2026-01-15 22:06:36 +01:00
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
2026-01-15 20:45:49 +01:00
Args:
customer_id: Stripe customer ID
payment_method_id: Payment method ID to verify
metadata: Additional metadata for tracking
2026-01-15 22:06:36 +01:00
2026-01-15 20:45:49 +01:00
Returns:
2026-01-15 22:06:36 +01:00
SetupIntent result for frontend confirmation
2026-01-15 20:45:49 +01:00
Raises:
SetupIntentError: If SetupIntent creation fails
2025-09-25 14:30:47 +02:00
"""
try:
2026-01-15 22:06:36 +01:00
# 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
2026-01-15 20:45:49 +01:00
setup_intent_params = {
'customer': customer_id,
'payment_method': payment_method_id,
2026-01-15 22:06:36 +01:00
'usage': 'off_session', # For future recurring payments
'confirm': True, # Confirm immediately - this triggers 3DS check
'idempotency_key': f"setup_intent_reg_{uuid.uuid4()}",
2026-01-15 20:45:49 +01:00
'metadata': metadata or {
'purpose': 'registration_payment_verification',
2026-01-15 22:06:36 +01:00
'timestamp': datetime.now(timezone.utc).isoformat()
},
'automatic_payment_methods': {
'enabled': True,
'allow_redirects': 'never'
2026-01-15 20:45:49 +01:00
}
}
2026-01-15 22:06:36 +01:00
2026-01-15 20:45:49 +01:00
setup_intent = stripe.SetupIntent.create(**setup_intent_params)
2026-01-15 22:06:36 +01:00
2026-01-15 20:45:49 +01:00
logger.info(
2026-01-15 22:06:36 +01:00
"SetupIntent created for verification",
2026-01-15 20:45:49 +01:00
extra={
"setup_intent_id": setup_intent.id,
"status": setup_intent.status,
"customer_id": customer_id,
"payment_method_id": payment_method_id
}
2025-09-25 14:30:47 +02:00
)
2026-01-15 22:06:36 +01:00
# Check if 3DS is required
requires_action = setup_intent.status in ['requires_action', 'requires_confirmation']
2026-01-15 20:45:49 +01:00
return {
'setup_intent_id': setup_intent.id,
'client_secret': setup_intent.client_secret,
'status': setup_intent.status,
2026-01-15 22:06:36 +01:00
'requires_action': requires_action,
2026-01-15 20:45:49 +01:00
'customer_id': customer_id,
'payment_method_id': payment_method_id,
'created': setup_intent.created,
2026-01-15 22:06:36 +01:00
'metadata': dict(setup_intent.metadata) if setup_intent.metadata else {}
2026-01-15 20:45:49 +01:00
}
2026-01-15 22:06:36 +01:00
2025-09-25 14:30:47 +02:00
except stripe.error.StripeError as e:
2026-01-15 20:45:49 +01:00
logger.error(
2026-01-15 22:06:36 +01:00
"SetupIntent creation for verification failed",
2026-01-15 20:45:49 +01:00
extra={
"error": str(e),
"error_type": type(e).__name__,
"customer_id": customer_id,
"payment_method_id": payment_method_id
},
exc_info=True
)
raise SetupIntentError(f"SetupIntent creation failed: {str(e)}") from e
except Exception as e:
logger.error(
2026-01-15 22:06:36 +01:00
"Unexpected error creating SetupIntent for verification",
2026-01-15 20:45:49 +01:00
extra={
"error": str(e),
"customer_id": customer_id,
"payment_method_id": payment_method_id
},
exc_info=True
)
raise SetupIntentError(f"Unexpected SetupIntent error: {str(e)}") from e
2026-01-15 22:06:36 +01:00
# 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
2026-01-15 20:45:49 +01:00
async def verify_setup_intent_status(
self,
setup_intent_id: str
) -> Dict[str, Any]:
2025-09-25 14:30:47 +02:00
"""
2026-01-15 20:45:49 +01:00
Atomic: Verify SetupIntent status after frontend confirmation
Args:
setup_intent_id: SetupIntent ID to verify
2026-01-14 13:15:48 +01:00
Returns:
2026-01-15 20:45:49 +01:00
SetupIntent verification result
Raises:
SetupIntentError: If verification fails
2025-09-25 14:30:47 +02:00
"""
try:
2026-01-15 20:45:49 +01:00
# Retrieve the SetupIntent to check its status
setup_intent = stripe.SetupIntent.retrieve(setup_intent_id)
logger.info(
"SetupIntent verification result",
extra={
"setup_intent_id": setup_intent.id,
"status": setup_intent.status,
"customer_id": setup_intent.customer
}
2025-09-25 14:30:47 +02:00
)
2026-01-15 20:45:49 +01:00
# Check if SetupIntent was successfully verified
if setup_intent.status == 'succeeded':
2026-01-14 13:15:48 +01:00
return {
2026-01-15 20:45:49 +01:00
'setup_intent_id': setup_intent.id,
'status': setup_intent.status,
'customer_id': setup_intent.customer,
'payment_method_id': setup_intent.payment_method,
'verified': True,
'requires_action': False,
2026-01-15 22:06:36 +01:00
'last_setup_error': setup_intent.last_setup_error,
'metadata': dict(setup_intent.metadata) if setup_intent.metadata else {}
2026-01-14 13:15:48 +01:00
}
2026-01-15 20:45:49 +01:00
elif setup_intent.status == 'requires_action':
return {
'setup_intent_id': setup_intent.id,
'status': setup_intent.status,
'customer_id': setup_intent.customer,
'payment_method_id': setup_intent.payment_method,
'verified': False,
'requires_action': True,
'client_secret': setup_intent.client_secret,
'next_action': setup_intent.next_action
}
else:
# Failed or other status
return {
'setup_intent_id': setup_intent.id,
'status': setup_intent.status,
'customer_id': setup_intent.customer,
'payment_method_id': setup_intent.payment_method,
'verified': False,
'requires_action': False,
'last_setup_error': setup_intent.last_setup_error
}
except stripe.error.StripeError as e:
logger.error(
"Stripe SetupIntent verification failed",
extra={
"error": str(e),
"error_type": type(e).__name__,
"setup_intent_id": setup_intent_id
},
exc_info=True
)
raise SetupIntentError(f"SetupIntent verification failed: {str(e)}") from e
except Exception as e:
logger.error(
"Unexpected error verifying SetupIntent",
extra={
"error": str(e),
"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[Any] = None,
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Atomic: Create subscription with verified payment method
ONLY called after successful SetupIntent verification
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
Args:
customer_id: Stripe customer ID
price_id: Stripe price ID
payment_method_id: Verified payment method ID
trial_period_days: Optional trial period in days
billing_cycle_anchor: Optional billing cycle anchor - can be:
- int: Unix timestamp for future billing anchor
- str "now": Start billing immediately (default behavior)
- str "unchanged": Keep existing anchor (for plan changes)
metadata: Additional metadata
Returns:
Subscription creation result with keys:
- subscription_id: Stripe subscription ID
- customer_id: Stripe customer ID
- status: Subscription status
- current_period_start: ISO timestamp
- current_period_end: ISO timestamp
- payment_intent_id: Payment intent ID if applicable
- requires_action: Boolean if payment action needed
- client_secret: Client secret for payment verification
- trial_period_days: Trial period days if applicable
- billing_cycle_anchor: Billing cycle anchor
- created: Subscription creation timestamp
- metadata: Subscription metadata
Raises:
SubscriptionCreationFailed: If subscription creation fails
Stripe Best Practices:
- For trials: Attach payment method to customer, not subscription
- For non-trials: Attach payment method to subscription
- Always use 3D Secure for card payments
- Use proper billing_cycle_anchor for trial timing
- Handle requires_action for payment verification
"""
try:
# Build base subscription parameters
2025-09-25 14:30:47 +02:00
subscription_params = {
'customer': customer_id,
2026-01-15 20:45:49 +01:00
'items': [{'price': price_id}],
'expand': ['latest_invoice.payment_intent'],
'idempotency_key': f"subscription_{uuid.uuid4()}",
'proration_behavior': 'none', # Default to no proration
'payment_settings': {
'payment_method_options': {
'card': {
'request_three_d_secure': 'automatic' # Always use 3DS
}
},
'save_default_payment_method': 'on_subscription'
}
}
# Handle metadata - preserve original and add base metadata
base_metadata = metadata or {
'purpose': 'registration_subscription',
'timestamp': datetime.now().isoformat()
2025-09-25 14:30:47 +02:00
}
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
# Add trial-specific parameters if trial period is specified
if trial_period_days is not None and trial_period_days > 0:
2025-09-25 14:30:47 +02:00
subscription_params['trial_period_days'] = trial_period_days
2026-01-15 20:45:49 +01:00
# Note: trial_from_plan is NOT used here because it conflicts with trial_period_days
# Stripe API: "You cannot set trial_end or trial_period_days when trial_from_plan=true"
subscription_params['off_session'] = True
# For trial subscriptions, don't set billing_cycle_anchor
# Stripe will automatically handle the trial timing
# Remove billing_cycle_anchor if it was set for non-trial subscriptions
subscription_params.pop('billing_cycle_anchor', None)
# Add trial-specific metadata
base_metadata.update({
'trial_subscription': 'true',
'trial_period_days': str(trial_period_days),
'no_immediate_payment': 'true',
'trial_start_date': datetime.now().isoformat(),
'payment_strategy': 'attach_to_customer'
})
# For trial subscriptions, attach payment method to customer FIRST
# This is required by Stripe for subscriptions with trial periods
try:
stripe.PaymentMethod.attach(
payment_method_id,
customer=customer_id
)
logger.info("Payment method attached to customer for trial subscription",
extra={"payment_method_id": payment_method_id, "customer_id": customer_id})
except Exception as e:
logger.error("Failed to attach payment method to customer for trial subscription",
extra={"error": str(e), "payment_method_id": payment_method_id, "customer_id": customer_id},
exc_info=True)
raise SubscriptionCreationFailed(f"Failed to attach payment method: {str(e)}") from e
# THEN set it as default payment method on customer
# This prevents immediate charge while ensuring payment method is available
stripe.Customer.modify(
customer_id,
invoice_settings={
'default_payment_method': payment_method_id
}
)
else:
# For non-trial subscriptions, attach payment method to subscription
subscription_params['default_payment_method'] = payment_method_id
subscription_params['proration_behavior'] = 'create_prorations'
# Add non-trial metadata
base_metadata.update({
'payment_strategy': 'attach_to_subscription',
'trial_period_days': '0'
})
# Handle billing cycle anchor parameter (override if explicitly provided)
# But for trial subscriptions, always use "now" to start trial immediately
if trial_period_days is not None and trial_period_days > 0:
# Trial subscription - billing_cycle_anchor was removed earlier
pass # Don't set billing_cycle_anchor for trials
elif billing_cycle_anchor is not None:
# Stripe requires billing_cycle_anchor to be a Unix timestamp (integer)
# If "now" is passed, don't set it - Stripe defaults to immediate start
# If "unchanged" is passed, also skip it (used for plan changes)
if isinstance(billing_cycle_anchor, str):
if billing_cycle_anchor.lower() in ('now', 'unchanged'):
# Skip setting billing_cycle_anchor - Stripe will use current time
pass
else:
# Try to parse as integer timestamp
try:
subscription_params['billing_cycle_anchor'] = int(billing_cycle_anchor)
except ValueError:
logger.warning(
"Invalid billing_cycle_anchor value, skipping",
extra={"billing_cycle_anchor": billing_cycle_anchor}
)
elif isinstance(billing_cycle_anchor, int):
subscription_params['billing_cycle_anchor'] = billing_cycle_anchor
subscription_params['metadata'] = base_metadata
# Log subscription parameters for debugging (redact sensitive data)
logger.debug(
"Creating Stripe subscription with parameters",
extra={
"customer_id": customer_id,
"price_id": price_id,
"trial_period_days": trial_period_days,
"billing_cycle_anchor": billing_cycle_anchor,
"payment_method_id": payment_method_id,
"metadata_keys": list(base_metadata.keys()) if base_metadata else []
}
)
# Create subscription
try:
stripe_subscription = stripe.Subscription.create(**subscription_params)
except Exception as e:
logger.error(
"Stripe API call failed during subscription creation",
extra={
"error": str(e),
"customer_id": customer_id,
"price_id": price_id,
"trial_period_days": trial_period_days,
"billing_cycle_anchor": billing_cycle_anchor
},
exc_info=True
)
raise SubscriptionCreationFailed(f"Stripe API failed: {str(e)}") from e
# Validate that we got a proper subscription object
if not hasattr(stripe_subscription, 'id') or not hasattr(stripe_subscription, 'status'):
logger.error(
"Invalid Stripe API response - not a subscription object",
extra={
"response_type": type(stripe_subscription).__name__,
"response_keys": list(stripe_subscription.keys()) if hasattr(stripe_subscription, 'keys') else 'N/A',
"customer_id": customer_id,
"price_id": price_id
}
)
raise SubscriptionCreationFailed(
f"Invalid Stripe API response: expected Subscription object, got {type(stripe_subscription).__name__}"
)
# Validate subscription creation
status = stripe_subscription.status
# Check if subscription is in a valid state before extracting timestamps
if status not in ['trialing', 'active', 'incomplete', 'past_due', 'canceled', 'unpaid']:
subscription_id = getattr(stripe_subscription, 'id', 'unknown')
raise SubscriptionCreationFailed(
f"Invalid subscription status: {status}. "
f"Subscription ID: {subscription_id}"
)
# For incomplete subscriptions, we can't extract timestamps as they may not be set yet
if status == 'incomplete':
subscription_id = getattr(stripe_subscription, 'id', 'unknown')
logger.warning(
"Incomplete subscription created - timestamps may not be available",
extra={
"subscription_id": subscription_id,
"customer_id": customer_id,
"status": status
}
)
# Set timestamps to None for incomplete subscriptions
current_period_start = None
current_period_end = None
2026-01-13 22:22:38 +01:00
else:
2026-01-15 20:45:49 +01:00
# Extract period dates using helper method for valid subscriptions
logger.debug(
"Extracting timestamps from Stripe subscription",
extra={
"current_period_start_raw": getattr(stripe_subscription, 'current_period_start', None),
"current_period_start_type": type(getattr(stripe_subscription, 'current_period_start', None)).__name__,
"current_period_end_raw": getattr(stripe_subscription, 'current_period_end', None),
"current_period_end_type": type(getattr(stripe_subscription, 'current_period_end', None)).__name__
}
)
current_period_start = self._extract_timestamp(getattr(stripe_subscription, 'current_period_start', None))
current_period_end = self._extract_timestamp(getattr(stripe_subscription, 'current_period_end', None))
logger.debug(
"Extracted timestamps",
extra={
"current_period_start": current_period_start,
"current_period_end": current_period_end,
"subscription_id": getattr(stripe_subscription, 'id', 'unknown')
}
)
# Check payment requirements
2026-01-14 13:15:48 +01:00
requires_action = False
client_secret = None
2026-01-15 20:45:49 +01:00
payment_intent_id = None
setup_intent_id = None
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
# First check PaymentIntent from latest_invoice (for non-trial subscriptions)
if (stripe_subscription.latest_invoice and
hasattr(stripe_subscription.latest_invoice, 'payment_intent')):
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
payment_intent = stripe_subscription.latest_invoice.payment_intent
if payment_intent:
payment_intent_id = payment_intent.id
2026-01-14 13:15:48 +01:00
if payment_intent.status in ['requires_action', 'requires_source_action']:
requires_action = True
client_secret = payment_intent.client_secret
2026-01-15 20:45:49 +01:00
# For trial subscriptions, check pending_setup_intent for 3DS requirement
# This is critical: trial subscriptions have $0 invoices with no PaymentIntent,
# but Stripe creates a SetupIntent to verify the card for future payments
pending_setup_intent = getattr(stripe_subscription, 'pending_setup_intent', None)
if pending_setup_intent and not requires_action:
# pending_setup_intent can be a string ID or an expanded object
if isinstance(pending_setup_intent, str):
# Need to retrieve the SetupIntent to check its status
try:
setup_intent = stripe.SetupIntent.retrieve(pending_setup_intent)
setup_intent_id = setup_intent.id
if setup_intent.status in ['requires_action', 'requires_confirmation']:
requires_action = True
client_secret = setup_intent.client_secret
logger.info(
"Trial subscription requires SetupIntent verification (3DS)",
extra={
"subscription_id": stripe_subscription.id,
"setup_intent_id": setup_intent_id,
"setup_intent_status": setup_intent.status
}
)
except Exception as e:
logger.warning(
"Failed to retrieve pending_setup_intent, continuing without 3DS check",
extra={"setup_intent_id": pending_setup_intent, "error": str(e)}
)
elif hasattr(pending_setup_intent, 'id'):
# Already expanded
setup_intent_id = pending_setup_intent.id
if pending_setup_intent.status in ['requires_action', 'requires_confirmation']:
requires_action = True
client_secret = pending_setup_intent.client_secret
logger.info(
"Trial subscription requires SetupIntent verification (3DS)",
extra={
"subscription_id": stripe_subscription.id,
"setup_intent_id": setup_intent_id,
"setup_intent_status": pending_setup_intent.status
}
)
# Validate trial subscriptions
if trial_period_days is not None and trial_period_days > 0:
self._validate_trial_subscription(
stripe_subscription, trial_period_days, requires_action
)
logger.info(
"Subscription created successfully",
extra={
"subscription_id": stripe_subscription.id,
"customer_id": customer_id,
"status": status,
"requires_action": requires_action,
"setup_intent_id": setup_intent_id,
"trial_period_days": trial_period_days
}
2025-09-25 14:30:47 +02:00
)
2026-01-14 13:15:48 +01:00
return {
2026-01-15 20:45:49 +01:00
'subscription_id': stripe_subscription.id,
'customer_id': customer_id,
'status': status,
'current_period_start': current_period_start,
'current_period_end': current_period_end,
'payment_intent_id': payment_intent_id,
'setup_intent_id': setup_intent_id,
2026-01-14 13:15:48 +01:00
'requires_action': requires_action,
'client_secret': client_secret,
2026-01-15 20:45:49 +01:00
'trial_period_days': trial_period_days,
'billing_cycle_anchor': getattr(stripe_subscription, 'billing_cycle_anchor', None),
'created': getattr(stripe_subscription, 'created', None),
'metadata': getattr(stripe_subscription, 'metadata', {})
2026-01-14 13:15:48 +01:00
}
2026-01-15 20:45:49 +01:00
2025-09-25 14:30:47 +02:00
except stripe.error.StripeError as e:
2026-01-15 20:45:49 +01:00
logger.error(
"Stripe subscription creation 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",
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
2025-09-25 14:30:47 +02:00
2026-01-15 20:45:49 +01:00
def _validate_trial_subscription(self, stripe_subscription, trial_period_days, requires_action):
"""Validate that trial subscription was created correctly"""
# Check status
if stripe_subscription.status not in ['trialing', 'active']:
raise SubscriptionCreationFailed(
f"Invalid trial subscription status: {stripe_subscription.status}. "
f"Expected 'trialing' or 'active'. Subscription ID: {stripe_subscription.id}"
)
# Check trial end is set
if not stripe_subscription.trial_end:
raise SubscriptionCreationFailed(
f"Trial subscription missing trial_end. Subscription ID: {stripe_subscription.id}"
)
# Check for immediate payment requirements during trial
if (stripe_subscription.latest_invoice and
hasattr(stripe_subscription.latest_invoice, 'amount_due')):
amount_due = stripe_subscription.latest_invoice.amount_due
current_time = int(datetime.now().timestamp())
# If we're still in the trial period
if stripe_subscription.trial_end > current_time:
if amount_due > 0:
# This is problematic - trial should have $0 due
logger.warning(
"Trial subscription has non-zero amount due during trial",
extra={
"subscription_id": stripe_subscription.id,
"amount_due": amount_due,
"trial_period_days": trial_period_days,
"trial_end": stripe_subscription.trial_end,
"current_time": current_time
}
)
# For now, we allow this but log it - the frontend can handle payment verification
else:
# This is expected - trial with $0 due
logger.info(
"Trial subscription created correctly with $0 amount due",
extra={
"subscription_id": stripe_subscription.id,
"trial_period_days": trial_period_days,
"trial_end": stripe_subscription.trial_end
}
)
def _extract_timestamp(self, timestamp):
"""Extract and format timestamp from Stripe response"""
if not timestamp:
return None
try:
if isinstance(timestamp, int):
return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat()
elif hasattr(timestamp, 'isoformat'):
return timestamp.isoformat()
elif isinstance(timestamp, str):
# Handle string timestamps - try to parse as ISO format first
try:
# Try to parse as ISO format
parsed_dt = datetime.fromisoformat(timestamp)
if parsed_dt.tzinfo is None:
parsed_dt = parsed_dt.replace(tzinfo=timezone.utc)
return parsed_dt.isoformat()
except ValueError:
# If not ISO format, try to parse as Unix timestamp
try:
return datetime.fromtimestamp(float(timestamp), tz=timezone.utc).isoformat()
except (ValueError, TypeError):
# If all parsing fails, return the string as-is
return timestamp
else:
return str(timestamp)
except Exception as e:
logger.error(
"Failed to extract timestamp from Stripe response",
extra={
"timestamp_value": str(timestamp),
"timestamp_type": type(timestamp).__name__,
"error": str(e)
},
exc_info=True
)
# Return the raw value as string if conversion fails
return str(timestamp)
async def create_customer(
2026-01-14 13:15:48 +01:00
self,
2026-01-15 20:45:49 +01:00
email: str,
name: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
2026-01-14 13:15:48 +01:00
) -> Dict[str, Any]:
"""
2026-01-15 20:45:49 +01:00
Atomic: Create Stripe customer
2026-01-14 13:15:48 +01:00
Args:
2026-01-15 20:45:49 +01:00
email: Customer email
name: Customer name
metadata: Additional metadata
2026-01-14 13:15:48 +01:00
Returns:
2026-01-15 20:45:49 +01:00
Customer creation result
Raises:
Exception: If customer creation fails
2026-01-14 13:15:48 +01:00
"""
try:
2026-01-15 20:45:49 +01:00
customer_params = {
'email': email,
'idempotency_key': f"customer_{uuid.uuid4()}",
'metadata': metadata or {
'purpose': 'registration_customer',
'timestamp': datetime.now().isoformat()
}
2026-01-14 13:15:48 +01:00
}
2026-01-15 20:45:49 +01:00
if name:
customer_params['name'] = name
customer = stripe.Customer.create(**customer_params)
logger.info(
"Stripe customer created",
extra={
"customer_id": customer.id,
"email": email
}
2026-01-14 13:15:48 +01:00
)
2026-01-15 20:45:49 +01:00
return customer
2026-01-14 13:15:48 +01:00
except stripe.error.StripeError as e:
2026-01-15 20:45:49 +01:00
logger.error(
"Stripe customer creation failed",
extra={
"error": str(e),
"error_type": type(e).__name__,
"email": email
},
exc_info=True
)
raise Exception(f"Customer creation failed: {str(e)}") from e
except Exception as e:
logger.error(
"Unexpected error creating customer",
extra={
"error": str(e),
"email": email
},
exc_info=True
)
raise Exception(f"Unexpected customer error: {str(e)}") from e
async def attach_payment_method(
self,
payment_method_id: str,
customer_id: str
) -> Dict[str, Any]:
2025-09-25 14:30:47 +02:00
"""
2026-01-15 20:45:49 +01:00
Atomic: Attach payment method to customer
Args:
payment_method_id: Payment method ID
customer_id: Customer ID
2026-01-14 13:15:48 +01:00
Returns:
2026-01-15 20:45:49 +01:00
Payment method attachment result
Raises:
Exception: If attachment fails
2025-09-25 14:30:47 +02:00
"""
try:
2026-01-15 20:45:49 +01:00
payment_method = stripe.PaymentMethod.attach(
payment_method_id,
customer=customer_id,
idempotency_key=f"attach_pm_{uuid.uuid4()}"
)
logger.info(
"Payment method attached to customer",
extra={
"payment_method_id": payment_method.id,
"customer_id": customer_id
}
)
return {
'payment_method_id': payment_method.id,
'customer_id': payment_method.customer,
'card_last4': payment_method.card.last4 if payment_method.card else None,
'card_brand': payment_method.card.brand if payment_method.card else None
}
except stripe.error.InvalidRequestError as e:
if 'already been attached' in str(e):
logger.warning(
"Payment method already attached",
extra={
"payment_method_id": payment_method_id,
"customer_id": customer_id
}
2026-01-13 22:22:38 +01:00
)
2026-01-15 20:45:49 +01:00
return {
'payment_method_id': payment_method_id,
'customer_id': customer_id,
'already_attached': True
}
else:
raise Exception(f"Payment method attachment failed: {str(e)}") from e
except Exception as e:
logger.error(
"Unexpected error attaching payment method",
extra={
"error": str(e),
"payment_method_id": payment_method_id,
"customer_id": customer_id
},
exc_info=True
)
raise Exception(f"Unexpected attachment error: {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:
Exception: If update fails
"""
try:
2026-01-14 13:15:48 +01:00
customer = stripe.Customer.modify(
2025-09-25 14:30:47 +02:00
customer_id,
invoice_settings={
'default_payment_method': payment_method_id
2026-01-15 20:45:49 +01:00
},
idempotency_key=f"default_pm_{uuid.uuid4()}"
)
logger.info(
"Default payment method updated",
extra={
"customer_id": customer_id,
"payment_method_id": payment_method_id
2025-09-25 14:30:47 +02:00
}
)
2026-01-15 20:45:49 +01:00
return {
'customer_id': customer.id,
'default_payment_method': customer.invoice_settings.default_payment_method
}
except Exception as e:
logger.error(
"Failed to update default payment method",
extra={
"error": str(e),
"customer_id": customer_id,
"payment_method_id": payment_method_id
},
exc_info=True
)
raise Exception(f"Default payment method update failed: {str(e)}") from e
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
async def create_setup_intent(self) -> Dict[str, Any]:
"""
Create a basic SetupIntent
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
Returns:
SetupIntent creation result
"""
try:
setup_intent = stripe.SetupIntent.create(
usage='off_session',
idempotency_key=f"setup_intent_{uuid.uuid4()}"
)
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
logger.info(
"Basic SetupIntent created",
extra={
"setup_intent_id": setup_intent.id
}
)
2026-01-14 13:15:48 +01:00
return {
2026-01-15 20:45:49 +01:00
'setup_intent_id': setup_intent.id,
'client_secret': setup_intent.client_secret,
'status': setup_intent.status
2026-01-14 13:15:48 +01:00
}
2026-01-15 20:45:49 +01:00
2025-09-25 14:30:47 +02:00
except stripe.error.StripeError as e:
2026-01-15 20:45:49 +01:00
logger.error(
"Basic SetupIntent creation failed",
extra={
"error": str(e)
},
exc_info=True
)
raise SetupIntentError(f"SetupIntent creation failed: {str(e)}") from e
async def get_setup_intent(self, setup_intent_id: str) -> Any:
2025-09-25 14:30:47 +02:00
"""
2026-01-15 20:45:49 +01:00
Get SetupIntent details
2026-01-13 22:22:38 +01:00
Args:
2026-01-15 20:45:49 +01:00
setup_intent_id: SetupIntent ID
2026-01-13 22:22:38 +01:00
Returns:
2026-01-15 20:45:49 +01:00
SetupIntent object
2025-09-25 14:30:47 +02:00
"""
try:
2026-01-15 20:45:49 +01:00
setup_intent = stripe.SetupIntent.retrieve(setup_intent_id)
return setup_intent
2025-09-25 14:30:47 +02:00
except stripe.error.StripeError as e:
2026-01-15 20:45:49 +01:00
logger.error(
"Failed to retrieve SetupIntent",
extra={
"error": str(e),
"setup_intent_id": setup_intent_id
},
exc_info=True
)
raise SetupIntentError(f"Failed to retrieve SetupIntent: {str(e)}") from e
async def create_payment_intent(
self,
amount: float,
currency: str,
customer_id: str,
payment_method_id: str
) -> Dict[str, Any]:
2025-09-25 14:30:47 +02:00
"""
2026-01-15 20:45:49 +01:00
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
2025-09-25 14:30:47 +02:00
"""
try:
2026-01-15 20:45:49 +01:00
payment_intent = stripe.PaymentIntent.create(
amount=int(amount * 100), # Convert to cents
currency=currency,
customer=customer_id,
payment_method=payment_method_id,
confirm=True,
idempotency_key=f"payment_intent_{uuid.uuid4()}"
)
2025-10-31 11:54:19 +01:00
2026-01-15 20:45:49 +01:00
logger.info(
"PaymentIntent created",
extra={
"payment_intent_id": payment_intent.id,
"status": payment_intent.status
}
)
2025-10-31 11:54:19 +01:00
2026-01-15 20:45:49 +01:00
return {
'payment_intent_id': payment_intent.id,
'status': payment_intent.status,
'client_secret': payment_intent.client_secret,
'requires_action': payment_intent.status == 'requires_action'
}
2025-10-31 11:54:19 +01:00
2025-09-25 14:30:47 +02:00
except stripe.error.StripeError as e:
2026-01-15 20:45:49 +01:00
logger.error(
"PaymentIntent creation failed",
extra={
"error": str(e)
},
exc_info=True
)
raise PaymentVerificationError(f"PaymentIntent creation failed: {str(e)}") from e
async def complete_subscription_after_setup_intent(
self,
setup_intent_id: str
) -> Dict[str, Any]:
2025-09-25 14:30:47 +02:00
"""
2026-01-15 20:45:49 +01:00
Complete subscription creation after SetupIntent verification
Note: This retrieves the SetupIntent and returns its details.
The actual subscription creation should use create_subscription_with_verified_payment.
Args:
setup_intent_id: SetupIntent ID
Returns:
SetupIntent verification result
2025-09-25 14:30:47 +02:00
"""
2026-01-15 20:45:49 +01:00
return await self.verify_setup_intent_status(setup_intent_id)
2026-01-13 22:22:38 +01:00
2026-01-15 20:45:49 +01:00
async def cancel_subscription(
2026-01-13 22:22:38 +01:00
self,
2026-01-15 20:45:49 +01:00
subscription_id: str
) -> Dict[str, Any]:
"""
Cancel a subscription at period end
2026-01-13 22:22:38 +01:00
Args:
2026-01-15 20:45:49 +01:00
subscription_id: Subscription ID
2026-01-13 22:22:38 +01:00
Returns:
2026-01-15 20:45:49 +01:00
Subscription cancellation result
2026-01-13 22:22:38 +01:00
"""
try:
2026-01-15 20:45:49 +01:00
subscription = stripe.Subscription.modify(
2026-01-13 22:22:38 +01:00
subscription_id,
2026-01-15 20:45:49 +01:00
cancel_at_period_end=True,
idempotency_key=f"cancel_sub_{uuid.uuid4()}"
)
2026-01-13 22:22:38 +01:00
2026-01-15 20:45:49 +01:00
logger.info(
"Subscription set to cancel at period end",
extra={
"subscription_id": subscription_id,
"cancel_at_period_end": subscription.cancel_at_period_end
}
2026-01-13 22:22:38 +01:00
)
2026-01-15 20:45:49 +01:00
return {
'subscription_id': subscription.id,
'status': subscription.status,
'cancel_at_period_end': subscription.cancel_at_period_end,
'current_period_end': subscription.current_period_end
}
2026-01-13 22:22:38 +01:00
except stripe.error.StripeError as e:
2026-01-15 20:45:49 +01:00
logger.error(
"Subscription cancellation failed",
extra={
"error": str(e),
"subscription_id": subscription_id
},
exc_info=True
)
raise SubscriptionCreationFailed(f"Subscription cancellation failed: {str(e)}") from e
2026-01-13 22:22:38 +01:00
2026-01-15 20:45:49 +01:00
async def update_payment_method(
2026-01-13 22:22:38 +01:00
self,
2026-01-15 20:45:49 +01:00
customer_id: str,
payment_method_id: str
2026-01-13 22:22:38 +01:00
) -> Dict[str, Any]:
"""
2026-01-15 20:45:49 +01:00
Update customer's payment method
2026-01-13 22:22:38 +01:00
Args:
2026-01-15 20:45:49 +01:00
customer_id: Customer ID
payment_method_id: New payment method ID
2026-01-13 22:22:38 +01:00
Returns:
2026-01-15 20:45:49 +01:00
Payment method update result
2026-01-13 22:22:38 +01:00
"""
try:
2026-01-15 20:45:49 +01:00
# Attach payment method to customer
await self.attach_payment_method(payment_method_id, customer_id)
# Set as default
result = await self.set_default_payment_method(customer_id, payment_method_id)
return result
except Exception as e:
logger.error(
"Failed to update payment method",
extra={
"error": str(e),
"customer_id": customer_id,
"payment_method_id": payment_method_id
},
exc_info=True
)
raise Exception(f"Payment method update failed: {str(e)}") from e
2026-01-13 22:22:38 +01:00
2026-01-15 20:45:49 +01:00
async def update_subscription(
2026-01-13 22:22:38 +01:00
self,
subscription_id: str,
2026-01-16 20:25:45 +01:00
new_price_id: str,
proration_behavior: str = 'create_prorations',
preserve_trial: bool = False
2026-01-15 20:45:49 +01:00
) -> Dict[str, Any]:
2026-01-13 22:22:38 +01:00
"""
2026-01-15 20:45:49 +01:00
Update subscription price (plan upgrade/downgrade)
2026-01-13 22:22:38 +01:00
Args:
2026-01-15 20:45:49 +01:00
subscription_id: Subscription ID
new_price_id: New price ID
2026-01-16 20:25:45 +01:00
proration_behavior: How to handle proration ('create_prorations', 'none', 'always_invoice')
- 'none': No proration charges (useful during trial)
- 'create_prorations': Create prorated charges (default)
- 'always_invoice': Always create an invoice
preserve_trial: If True, preserves the trial period after upgrade
2026-01-15 20:45:49 +01:00
2026-01-13 22:22:38 +01:00
Returns:
2026-01-16 20:25:45 +01:00
Subscription update result including trial info
2026-01-13 22:22:38 +01:00
"""
try:
2026-01-15 20:45:49 +01:00
subscription = stripe.Subscription.retrieve(subscription_id)
2026-01-16 20:25:45 +01:00
# Check if subscription is currently trialing
is_trialing = subscription.status == 'trialing'
trial_end = subscription.trial_end
# Build the modification params
modify_params = {
'items': [{
2026-01-15 20:45:49 +01:00
'id': subscription['items']['data'][0].id,
'price': new_price_id,
}],
2026-01-16 20:25:45 +01:00
'proration_behavior': proration_behavior,
'idempotency_key': f"update_sub_{uuid.uuid4()}"
}
# Preserve trial period if requested and currently trialing
# When changing plans during trial, Stripe will by default end the trial
# By explicitly setting trial_end, we preserve it
if preserve_trial and is_trialing and trial_end:
modify_params['trial_end'] = trial_end
logger.info(
"Preserving trial period during upgrade",
extra={
"subscription_id": subscription_id,
"trial_end": trial_end
}
)
updated_subscription = stripe.Subscription.modify(
subscription_id,
**modify_params
2026-01-13 22:22:38 +01:00
)
2026-01-15 20:45:49 +01:00
logger.info(
"Subscription updated",
extra={
"subscription_id": subscription_id,
2026-01-16 20:25:45 +01:00
"new_price_id": new_price_id,
"proration_behavior": proration_behavior,
"was_trialing": is_trialing,
"new_status": updated_subscription.status
2026-01-15 20:45:49 +01:00
}
2025-09-25 14:30:47 +02:00
)
2026-01-15 20:45:49 +01:00
2025-09-25 14:30:47 +02:00
return {
2026-01-15 20:45:49 +01:00
'subscription_id': updated_subscription.id,
'status': updated_subscription.status,
2026-01-16 20:25:45 +01:00
'current_period_start': updated_subscription.current_period_start,
'current_period_end': updated_subscription.current_period_end,
'trial_end': updated_subscription.trial_end,
'is_trialing': updated_subscription.status == 'trialing',
'customer_id': updated_subscription.customer
2025-09-25 14:30:47 +02:00
}
2026-01-15 20:45:49 +01:00
2025-09-25 14:30:47 +02:00
except stripe.error.StripeError as e:
2026-01-15 20:45:49 +01:00
logger.error(
"Subscription update failed",
extra={
"error": str(e),
"subscription_id": subscription_id
},
exc_info=True
)
raise SubscriptionCreationFailed(f"Subscription update failed: {str(e)}") from e
async def get_subscription(
self,
subscription_id: str
) -> Dict[str, Any]:
2025-09-25 14:30:47 +02:00
"""
2026-01-15 20:45:49 +01:00
Get subscription details
Args:
subscription_id: Subscription ID
Returns:
Subscription details
2025-09-25 14:30:47 +02:00
"""
try:
2026-01-15 20:45:49 +01:00
subscription = stripe.Subscription.retrieve(subscription_id)
# Safely extract current_period_start (may not be present in all subscription types)
current_period_start = None
try:
# Use getattr with default to safely access the attribute
current_period_start = getattr(subscription, 'current_period_start', None)
if current_period_start:
# Convert to ISO format if it's a timestamp
if isinstance(current_period_start, int):
current_period_start = datetime.fromtimestamp(current_period_start, tz=timezone.utc).isoformat()
elif hasattr(current_period_start, 'isoformat'):
current_period_start = current_period_start.isoformat()
elif isinstance(current_period_start, str):
# Handle string timestamps
try:
# Try to parse as ISO format
parsed_dt = datetime.fromisoformat(current_period_start)
if parsed_dt.tzinfo is None:
parsed_dt = parsed_dt.replace(tzinfo=timezone.utc)
current_period_start = parsed_dt.isoformat()
except ValueError:
# If not ISO format, try to parse as Unix timestamp
try:
current_period_start = datetime.fromtimestamp(float(current_period_start), tz=timezone.utc).isoformat()
except (ValueError, TypeError):
# If all parsing fails, keep as string
pass
except Exception as e:
logger.warning(
"current_period_start not available in retrieved subscription",
extra={
"subscription_id": subscription.id,
"status": subscription.status,
"error": str(e)
}
)
current_period_start = None
# Safely extract current_period_end
current_period_end = None
try:
# Use getattr with default to safely access the attribute
current_period_end = getattr(subscription, 'current_period_end', None)
if current_period_end:
# Convert to ISO format if it's a timestamp
if isinstance(current_period_end, int):
current_period_end = datetime.fromtimestamp(current_period_end, tz=timezone.utc).isoformat()
elif hasattr(current_period_end, 'isoformat'):
current_period_end = current_period_end.isoformat()
elif isinstance(current_period_end, str):
# Handle string timestamps
try:
# Try to parse as ISO format
parsed_dt = datetime.fromisoformat(current_period_end)
if parsed_dt.tzinfo is None:
parsed_dt = parsed_dt.replace(tzinfo=timezone.utc)
current_period_end = parsed_dt.isoformat()
except ValueError:
# If not ISO format, try to parse as Unix timestamp
try:
current_period_end = datetime.fromtimestamp(float(current_period_end), tz=timezone.utc).isoformat()
except (ValueError, TypeError):
# If all parsing fails, keep as string
pass
except Exception as e:
logger.warning(
"current_period_end not available in retrieved subscription",
extra={
"subscription_id": subscription.id,
"status": subscription.status,
"error": str(e)
}
)
current_period_end = None
2026-01-14 13:15:48 +01:00
2025-09-25 14:30:47 +02:00
return {
2026-01-15 20:45:49 +01:00
'subscription_id': subscription.id,
'status': subscription.status,
'current_period_start': current_period_start,
'current_period_end': current_period_end,
'cancel_at_period_end': subscription.cancel_at_period_end,
'customer_id': subscription.customer
2025-09-25 14:30:47 +02:00
}
2026-01-15 20:45:49 +01:00
2025-09-25 14:30:47 +02:00
except stripe.error.StripeError as e:
2026-01-15 20:45:49 +01:00
logger.error("Failed to retrieve subscription",
error=str(e),
subscription_id=subscription_id,
exc_info=True)
raise SubscriptionCreationFailed(f"Failed to retrieve subscription: {str(e)}") from e
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
async def get_customer_payment_method(
self,
customer_id: str
) -> Dict[str, Any]:
2026-01-14 13:15:48 +01:00
"""
2026-01-15 20:45:49 +01:00
Get customer's default payment method
2026-01-14 13:15:48 +01:00
Args:
2026-01-15 20:45:49 +01:00
customer_id: Customer ID
2026-01-14 13:15:48 +01:00
Returns:
2026-01-15 20:45:49 +01:00
Payment method details
2026-01-14 13:15:48 +01:00
"""
try:
2026-01-15 20:45:49 +01:00
customer = stripe.Customer.retrieve(customer_id)
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
default_pm_id = None
if customer.invoice_settings and customer.invoice_settings.default_payment_method:
default_pm_id = customer.invoice_settings.default_payment_method
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
if not default_pm_id:
2026-01-14 13:15:48 +01:00
return None
2026-01-15 20:45:49 +01:00
payment_method = stripe.PaymentMethod.retrieve(default_pm_id)
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
return {
'payment_method_id': payment_method.id,
'type': payment_method.type,
'brand': payment_method.card.brand if payment_method.card else None,
'last4': payment_method.card.last4 if payment_method.card else None,
'exp_month': payment_method.card.exp_month if payment_method.card else None,
'exp_year': payment_method.card.exp_year if payment_method.card else None
}
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
except stripe.error.StripeError as e:
logger.error("Failed to retrieve customer payment method",
error=str(e),
customer_id=customer_id,
exc_info=True)
return None
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
async def get_invoices(
self,
customer_id: str
) -> Dict[str, Any]:
"""
Get customer invoices
Args:
customer_id: Customer ID
Returns:
List of invoices
"""
try:
invoices = stripe.Invoice.list(customer=customer_id, limit=10)
2026-01-16 20:25:45 +01:00
# Debug: Log all invoice IDs and amounts to see what Stripe returns
logger.info("stripe_invoices_retrieved",
customer_id=customer_id,
invoice_count=len(invoices.data),
invoice_ids=[inv.id for inv in invoices.data],
invoice_amounts=[inv.amount_due for inv in invoices.data])
2026-01-15 20:45:49 +01:00
return {
'invoices': [
{
'id': inv.id,
'amount_due': inv.amount_due / 100,
'amount_paid': inv.amount_paid / 100,
'status': inv.status,
'created': inv.created,
2026-01-16 20:25:45 +01:00
'invoice_pdf': inv.invoice_pdf,
'hosted_invoice_url': inv.hosted_invoice_url,
'number': inv.number,
'currency': inv.currency,
'description': inv.description,
'trial': inv.amount_due == 0 # Mark trial invoices
2026-01-15 20:45:49 +01:00
}
for inv in invoices.data
]
}
2026-01-14 13:15:48 +01:00
except stripe.error.StripeError as e:
2026-01-15 20:45:49 +01:00
logger.error("Failed to retrieve invoices",
error=str(e),
customer_id=customer_id,
exc_info=True)
return {'invoices': []}
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
async def update_subscription_payment_method(
self,
subscription_id: str,
payment_method_id: str
) -> Any:
2026-01-14 13:15:48 +01:00
"""
2026-01-15 20:45:49 +01:00
Update an existing subscription with a new payment method
2026-01-14 13:15:48 +01:00
Args:
2026-01-15 20:45:49 +01:00
subscription_id: Stripe subscription ID
payment_method_id: New payment method ID
2026-01-14 13:15:48 +01:00
Returns:
2026-01-15 20:45:49 +01:00
Updated Stripe subscription object
2026-01-14 13:15:48 +01:00
Raises:
2026-01-15 20:45:49 +01:00
SubscriptionUpdateFailed: If the update fails
2026-01-14 13:15:48 +01:00
"""
try:
2026-01-15 20:45:49 +01:00
logger.info("Updating subscription payment method in Stripe",
subscription_id=subscription_id,
payment_method_id=payment_method_id)
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
# Update the subscription's default payment method
stripe_subscription = stripe.Subscription.modify(
subscription_id,
default_payment_method=payment_method_id
)
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
logger.info("Subscription payment method updated in Stripe",
subscription_id=subscription_id,
status=stripe_subscription.status)
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
return stripe_subscription
2026-01-14 13:15:48 +01:00
2026-01-15 20:45:49 +01:00
except Exception as e:
logger.error(
"Failed to update subscription payment method in Stripe",
extra={
"subscription_id": subscription_id,
"payment_method_id": payment_method_id,
"error": str(e)
},
exc_info=True
)
raise SubscriptionUpdateFailed(f"Stripe API failed: {str(e)}") from e
2026-01-15 22:06:36 +01:00
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
2026-01-15 20:45:49 +01:00
# Singleton instance for dependency injection
stripe_client = StripeClient()