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