Files
bakery-ia/shared/clients/stripe_client.py
2026-01-13 22:22:38 +01:00

655 lines
29 KiB
Python
Executable File

"""
Stripe Payment Provider Implementation
This module implements the PaymentProvider interface for Stripe
"""
import stripe
import structlog
import uuid
from typing import Dict, Any, Optional
from datetime import datetime
from .payment_client import PaymentProvider, PaymentCustomer, PaymentMethod, Subscription, Invoice
logger = structlog.get_logger()
class StripeProvider(PaymentProvider):
"""
Stripe implementation of the PaymentProvider interface
"""
def __init__(self, api_key: str, webhook_secret: Optional[str] = None):
"""
Initialize the Stripe provider with API key
"""
stripe.api_key = api_key
self.webhook_secret = webhook_secret
async def create_customer(self, customer_data: Dict[str, Any]) -> PaymentCustomer:
"""
Create a customer in Stripe with idempotency key
"""
try:
idempotency_key = f"create_customer_{uuid.uuid4()}"
logger.info("Creating Stripe customer",
email=customer_data.get('email'),
customer_name=customer_data.get('name'))
stripe_customer = stripe.Customer.create(
email=customer_data.get('email'),
name=customer_data.get('name'),
phone=customer_data.get('phone'),
metadata=customer_data.get('metadata', {}),
idempotency_key=idempotency_key
)
logger.info("Stripe customer created successfully",
customer_id=stripe_customer.id)
return PaymentCustomer(
id=stripe_customer.id,
email=stripe_customer.email,
name=stripe_customer.name,
created_at=datetime.fromtimestamp(stripe_customer.created)
)
except stripe.error.StripeError as e:
logger.error("Failed to create Stripe customer",
error=str(e),
error_type=type(e).__name__,
email=customer_data.get('email'))
raise e
async def create_subscription(self, customer_id: str, plan_id: str, payment_method_id: str, trial_period_days: Optional[int] = None) -> Subscription:
"""
Create a subscription in Stripe with idempotency and enhanced error handling
"""
try:
subscription_idempotency_key = f"create_subscription_{uuid.uuid4()}"
payment_method_idempotency_key = f"attach_pm_{uuid.uuid4()}"
customer_update_idempotency_key = f"update_customer_{uuid.uuid4()}"
logger.info("Creating Stripe subscription",
customer_id=customer_id,
plan_id=plan_id,
payment_method_id=payment_method_id)
# Attach payment method to customer with idempotency and error handling
try:
stripe.PaymentMethod.attach(
payment_method_id,
customer=customer_id,
idempotency_key=payment_method_idempotency_key
)
logger.info("Payment method attached to customer",
customer_id=customer_id,
payment_method_id=payment_method_id)
except stripe.error.InvalidRequestError as e:
# Payment method may already be attached
if 'already been attached' in str(e):
logger.warning("Payment method already attached to customer",
customer_id=customer_id,
payment_method_id=payment_method_id)
else:
raise
# Set customer's default payment method with idempotency
stripe.Customer.modify(
customer_id,
invoice_settings={
'default_payment_method': payment_method_id
},
idempotency_key=customer_update_idempotency_key
)
logger.info("Customer default payment method updated",
customer_id=customer_id)
# Create subscription with trial period if specified
subscription_params = {
'customer': customer_id,
'items': [{'price': plan_id}],
'default_payment_method': payment_method_id,
'idempotency_key': subscription_idempotency_key,
'expand': ['latest_invoice.payment_intent']
}
if trial_period_days:
subscription_params['trial_period_days'] = trial_period_days
logger.info("Subscription includes trial period",
trial_period_days=trial_period_days)
stripe_subscription = stripe.Subscription.create(**subscription_params)
# Handle period dates for trial vs active subscriptions
# During trial: current_period_* fields are only in subscription items, not root
# After trial: current_period_* fields are at root level
if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data:
# For trial subscriptions, get period from first subscription item
first_item = stripe_subscription.items.data[0]
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
logger.info("Stripe trial subscription created successfully",
subscription_id=stripe_subscription.id,
status=stripe_subscription.status,
trial_end=stripe_subscription.trial_end,
current_period_end=current_period_end)
else:
# For active subscriptions, get period from root level
current_period_start = stripe_subscription.current_period_start
current_period_end = stripe_subscription.current_period_end
logger.info("Stripe subscription created successfully",
subscription_id=stripe_subscription.id,
status=stripe_subscription.status,
current_period_end=current_period_end)
return Subscription(
id=stripe_subscription.id,
customer_id=stripe_subscription.customer,
plan_id=plan_id, # Using the price ID as plan_id
status=stripe_subscription.status,
current_period_start=datetime.fromtimestamp(current_period_start),
current_period_end=datetime.fromtimestamp(current_period_end),
created_at=datetime.fromtimestamp(stripe_subscription.created)
)
except stripe.error.CardError as e:
logger.error("Card error during subscription creation",
error=str(e),
error_code=e.code,
decline_code=e.decline_code,
customer_id=customer_id)
raise e
except stripe.error.InvalidRequestError as e:
logger.error("Invalid request during subscription creation",
error=str(e),
param=e.param,
customer_id=customer_id)
raise e
except stripe.error.StripeError as e:
logger.error("Failed to create Stripe subscription",
error=str(e),
error_type=type(e).__name__,
customer_id=customer_id,
plan_id=plan_id)
raise e
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod:
"""
Update the payment method for a customer in Stripe
"""
try:
# Attach payment method to customer with error handling
try:
stripe.PaymentMethod.attach(
payment_method_id,
customer=customer_id,
)
logger.info("Payment method attached for update",
customer_id=customer_id,
payment_method_id=payment_method_id)
except stripe.error.InvalidRequestError as e:
# Payment method may already be attached
if 'already been attached' in str(e):
logger.warning("Payment method already attached, skipping attach",
customer_id=customer_id,
payment_method_id=payment_method_id)
else:
raise
# Set as default payment method
stripe.Customer.modify(
customer_id,
invoice_settings={
'default_payment_method': payment_method_id
}
)
stripe_payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
return PaymentMethod(
id=stripe_payment_method.id,
type=stripe_payment_method.type,
brand=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('brand'),
last4=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('last4'),
exp_month=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('exp_month'),
exp_year=getattr(stripe_payment_method, stripe_payment_method.type, {}).get('exp_year'),
)
except stripe.error.StripeError as e:
logger.error("Failed to update Stripe payment method", error=str(e))
raise e
async def cancel_subscription(
self,
subscription_id: str,
cancel_at_period_end: bool = True
) -> Subscription:
"""
Cancel a subscription in Stripe
Args:
subscription_id: Stripe subscription ID
cancel_at_period_end: If True, subscription continues until end of billing period.
If False, cancels immediately.
Returns:
Updated Subscription object
"""
try:
if cancel_at_period_end:
# Cancel at end of billing period (graceful cancellation)
stripe_subscription = stripe.Subscription.modify(
subscription_id,
cancel_at_period_end=True
)
logger.info("Subscription set to cancel at period end",
subscription_id=subscription_id,
cancel_at=stripe_subscription.trial_end if stripe_subscription.status == 'trialing' else stripe_subscription.current_period_end)
else:
# Cancel immediately
stripe_subscription = stripe.Subscription.delete(subscription_id)
logger.info("Subscription cancelled immediately",
subscription_id=subscription_id)
# Handle period dates for trial vs active subscriptions
if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data:
first_item = stripe_subscription.items.data[0]
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
else:
current_period_start = stripe_subscription.current_period_start
current_period_end = stripe_subscription.current_period_end
return Subscription(
id=stripe_subscription.id,
customer_id=stripe_subscription.customer,
plan_id=subscription_id,
status=stripe_subscription.status,
current_period_start=datetime.fromtimestamp(current_period_start),
current_period_end=datetime.fromtimestamp(current_period_end),
created_at=datetime.fromtimestamp(stripe_subscription.created)
)
except stripe.error.StripeError as e:
logger.error("Failed to cancel Stripe subscription", error=str(e))
raise e
async def get_invoices(self, customer_id: str) -> list[Invoice]:
"""
Get invoices for a customer from Stripe
"""
try:
stripe_invoices = stripe.Invoice.list(customer=customer_id, limit=100)
invoices = []
for stripe_invoice in stripe_invoices:
# Create base invoice object
invoice = Invoice(
id=stripe_invoice.id,
customer_id=stripe_invoice.customer,
subscription_id=stripe_invoice.subscription,
amount=stripe_invoice.amount_paid / 100.0, # Convert from cents
currency=stripe_invoice.currency,
status=stripe_invoice.status,
created_at=datetime.fromtimestamp(stripe_invoice.created),
due_date=datetime.fromtimestamp(stripe_invoice.due_date) if stripe_invoice.due_date else None,
description=stripe_invoice.description
)
# Add Stripe-specific URLs as custom attributes
invoice.invoice_pdf = stripe_invoice.invoice_pdf if hasattr(stripe_invoice, 'invoice_pdf') else None
invoice.hosted_invoice_url = stripe_invoice.hosted_invoice_url if hasattr(stripe_invoice, 'hosted_invoice_url') else None
invoices.append(invoice)
return invoices
except stripe.error.StripeError as e:
logger.error("Failed to retrieve Stripe invoices", error=str(e))
raise e
async def get_subscription(self, subscription_id: str) -> Subscription:
"""
Get subscription details from Stripe
"""
try:
stripe_subscription = stripe.Subscription.retrieve(subscription_id)
# Get the actual plan ID from the subscription items
plan_id = subscription_id # Default fallback
if stripe_subscription.items and stripe_subscription.items.data:
plan_id = stripe_subscription.items.data[0].price.id
# Handle period dates for trial vs active subscriptions
# During trial: current_period_* fields are only in subscription items, not root
# After trial: current_period_* fields are at root level
if stripe_subscription.status == 'trialing' and stripe_subscription.items and stripe_subscription.items.data:
# For trial subscriptions, get period from first subscription item
first_item = stripe_subscription.items.data[0]
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
else:
# For active subscriptions, get period from root level
current_period_start = stripe_subscription.current_period_start
current_period_end = stripe_subscription.current_period_end
return Subscription(
id=stripe_subscription.id,
customer_id=stripe_subscription.customer,
plan_id=plan_id,
status=stripe_subscription.status,
current_period_start=datetime.fromtimestamp(current_period_start),
current_period_end=datetime.fromtimestamp(current_period_end),
created_at=datetime.fromtimestamp(stripe_subscription.created),
billing_cycle_anchor=datetime.fromtimestamp(stripe_subscription.billing_cycle_anchor) if stripe_subscription.billing_cycle_anchor else None,
cancel_at_period_end=stripe_subscription.cancel_at_period_end
)
except stripe.error.StripeError as e:
logger.error("Failed to retrieve Stripe subscription", error=str(e))
raise e
async def update_subscription(
self,
subscription_id: str,
new_price_id: str,
proration_behavior: str = "create_prorations",
billing_cycle_anchor: str = "unchanged",
payment_behavior: str = "error_if_incomplete",
immediate_change: bool = False
) -> Subscription:
"""
Update a subscription in Stripe with proration support
Args:
subscription_id: Stripe subscription ID
new_price_id: New Stripe price ID to switch to
proration_behavior: How to handle prorations ('create_prorations', 'none', 'always_invoice')
billing_cycle_anchor: When to apply changes ('unchanged', 'now')
payment_behavior: Payment behavior ('error_if_incomplete', 'allow_incomplete')
immediate_change: Whether to apply changes immediately or at period end
Returns:
Updated Subscription object
"""
try:
logger.info("Updating Stripe subscription",
subscription_id=subscription_id,
new_price_id=new_price_id,
proration_behavior=proration_behavior,
immediate_change=immediate_change)
# Get current subscription to preserve settings
current_subscription = stripe.Subscription.retrieve(subscription_id)
# Build update parameters
update_params = {
'items': [{
'id': current_subscription.items.data[0].id,
'price': new_price_id,
}],
'proration_behavior': proration_behavior,
'billing_cycle_anchor': billing_cycle_anchor,
'payment_behavior': payment_behavior,
'expand': ['latest_invoice.payment_intent']
}
# If not immediate change, set cancel_at_period_end to False
# and let Stripe handle the transition
if not immediate_change:
update_params['cancel_at_period_end'] = False
update_params['proration_behavior'] = 'none' # No proration for end-of-period changes
# Update the subscription
updated_subscription = stripe.Subscription.modify(
subscription_id,
**update_params
)
logger.info("Stripe subscription updated successfully",
subscription_id=updated_subscription.id,
new_price_id=new_price_id,
status=updated_subscription.status)
# Get the actual plan ID from the subscription items
plan_id = new_price_id
if updated_subscription.items and updated_subscription.items.data:
plan_id = updated_subscription.items.data[0].price.id
# Handle period dates for trial vs active subscriptions
if updated_subscription.status == 'trialing' and updated_subscription.items and updated_subscription.items.data:
first_item = updated_subscription.items.data[0]
current_period_start = first_item.current_period_start
current_period_end = first_item.current_period_end
else:
current_period_start = updated_subscription.current_period_start
current_period_end = updated_subscription.current_period_end
return Subscription(
id=updated_subscription.id,
customer_id=updated_subscription.customer,
plan_id=plan_id,
status=updated_subscription.status,
current_period_start=datetime.fromtimestamp(current_period_start),
current_period_end=datetime.fromtimestamp(current_period_end),
created_at=datetime.fromtimestamp(updated_subscription.created),
billing_cycle_anchor=datetime.fromtimestamp(updated_subscription.billing_cycle_anchor) if updated_subscription.billing_cycle_anchor else None,
cancel_at_period_end=updated_subscription.cancel_at_period_end
)
except stripe.error.StripeError as e:
logger.error("Failed to update Stripe subscription",
error=str(e),
subscription_id=subscription_id,
new_price_id=new_price_id)
raise e
async def calculate_proration(
self,
subscription_id: str,
new_price_id: str,
proration_behavior: str = "create_prorations"
) -> Dict[str, Any]:
"""
Calculate proration amounts for a subscription change
Args:
subscription_id: Stripe subscription ID
new_price_id: New Stripe price ID
proration_behavior: Proration behavior to use
Returns:
Dictionary with proration details including amount, currency, and description
"""
try:
logger.info("Calculating proration for subscription change",
subscription_id=subscription_id,
new_price_id=new_price_id)
# Get current subscription
current_subscription = stripe.Subscription.retrieve(subscription_id)
current_price_id = current_subscription.items.data[0].price.id
# Get current and new prices
current_price = stripe.Price.retrieve(current_price_id)
new_price = stripe.Price.retrieve(new_price_id)
# Calculate time remaining in current billing period
current_period_end = datetime.fromtimestamp(current_subscription.current_period_end)
current_period_start = datetime.fromtimestamp(current_subscription.current_period_start)
now = datetime.now(timezone.utc)
total_period_days = (current_period_end - current_period_start).days
remaining_days = (current_period_end - now).days
used_days = (now - current_period_start).days
# Calculate prorated amounts
current_price_amount = current_price.unit_amount / 100.0 # Convert from cents
new_price_amount = new_price.unit_amount / 100.0
# Calculate daily rates
current_daily_rate = current_price_amount / total_period_days
new_daily_rate = new_price_amount / total_period_days
# Calculate proration based on behavior
if proration_behavior == "create_prorations":
# Calculate credit for unused time on current plan
unused_current_amount = current_daily_rate * remaining_days
# Calculate charge for remaining time on new plan
prorated_new_amount = new_daily_rate * remaining_days
# Net amount (could be positive or negative)
net_amount = prorated_new_amount - unused_current_amount
return {
"current_price_amount": current_price_amount,
"new_price_amount": new_price_amount,
"unused_current_amount": unused_current_amount,
"prorated_new_amount": prorated_new_amount,
"net_amount": net_amount,
"currency": current_price.currency.upper(),
"remaining_days": remaining_days,
"used_days": used_days,
"total_period_days": total_period_days,
"description": f"Proration for changing from {current_price_id} to {new_price_id}",
"is_credit": net_amount < 0
}
elif proration_behavior == "none":
return {
"current_price_amount": current_price_amount,
"new_price_amount": new_price_amount,
"net_amount": 0,
"currency": current_price.currency.upper(),
"description": "No proration - changes apply at period end",
"is_credit": False
}
else:
return {
"current_price_amount": current_price_amount,
"new_price_amount": new_price_amount,
"net_amount": new_price_amount - current_price_amount,
"currency": current_price.currency.upper(),
"description": "Full amount difference - immediate billing",
"is_credit": False
}
except stripe.error.StripeError as e:
logger.error("Failed to calculate proration",
error=str(e),
subscription_id=subscription_id,
new_price_id=new_price_id)
raise e
async def change_billing_cycle(
self,
subscription_id: str,
new_billing_cycle: str,
proration_behavior: str = "create_prorations"
) -> Subscription:
"""
Change billing cycle (monthly ↔ yearly) for a subscription
Args:
subscription_id: Stripe subscription ID
new_billing_cycle: New billing cycle ('monthly' or 'yearly')
proration_behavior: Proration behavior to use
Returns:
Updated Subscription object
"""
try:
logger.info("Changing billing cycle for subscription",
subscription_id=subscription_id,
new_billing_cycle=new_billing_cycle)
# Get current subscription
current_subscription = stripe.Subscription.retrieve(subscription_id)
current_price_id = current_subscription.items.data[0].price.id
# Get current price to determine the plan
current_price = stripe.Price.retrieve(current_price_id)
product_id = current_price.product
# Find the corresponding price for the new billing cycle
# This assumes you have price IDs set up for both monthly and yearly
# You would need to map this based on your product catalog
prices = stripe.Price.list(product=product_id, active=True)
new_price_id = None
for price in prices:
if price.recurring and price.recurring.interval == new_billing_cycle:
new_price_id = price.id
break
if not new_price_id:
raise ValueError(f"No {new_billing_cycle} price found for product {product_id}")
# Update the subscription with the new price
return await self.update_subscription(
subscription_id,
new_price_id,
proration_behavior=proration_behavior,
billing_cycle_anchor="now",
immediate_change=True
)
except stripe.error.StripeError as e:
logger.error("Failed to change billing cycle",
error=str(e),
subscription_id=subscription_id,
new_billing_cycle=new_billing_cycle)
raise e
async def get_customer(self, customer_id: str) -> PaymentCustomer:
"""
Get customer details from Stripe
"""
try:
stripe_customer = stripe.Customer.retrieve(customer_id)
return PaymentCustomer(
id=stripe_customer.id,
email=stripe_customer.email,
name=stripe_customer.name,
created_at=datetime.fromtimestamp(stripe_customer.created)
)
except stripe.error.StripeError as e:
logger.error("Failed to retrieve Stripe customer", error=str(e))
raise e
async def create_setup_intent(self) -> Dict[str, Any]:
"""
Create a setup intent for saving payment methods in Stripe
"""
try:
setup_intent = stripe.SetupIntent.create()
return {
'client_secret': setup_intent.client_secret,
'id': setup_intent.id
}
except stripe.error.StripeError as e:
logger.error("Failed to create Stripe setup intent", error=str(e))
raise e
async def create_payment_intent(self, amount: float, currency: str, customer_id: str, payment_method_id: str) -> Dict[str, Any]:
"""
Create a payment intent for one-time payments in Stripe
"""
try:
payment_intent = stripe.PaymentIntent.create(
amount=int(amount * 100), # Convert to cents
currency=currency,
customer=customer_id,
payment_method=payment_method_id,
confirm=True
)
return {
'id': payment_intent.id,
'client_secret': payment_intent.client_secret,
'status': payment_intent.status
}
except stripe.error.StripeError as e:
logger.error("Failed to create Stripe payment intent", error=str(e))
raise e