Add subcription feature 4

This commit is contained in:
Urtzi Alfaro
2026-01-15 22:06:36 +01:00
parent b674708a4c
commit 483a9f64cd
10 changed files with 1209 additions and 1390 deletions

View File

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