silver / stripe_service.py
Song
hi
9f487cc
"""
Stripe payment service for Silver Table Assistant.
Handles payment processing, checkout sessions, and webhooks.
"""
import os
import stripe
from typing import Optional, Dict, Any
from uuid import UUID
from config import settings
from exceptions import PaymentException, handle_payment_error
# Initialize Stripe API key
stripe.api_key = settings.stripe_secret_key
def create_checkout_session_for_order(order, customer_email: str = None) -> str:
"""
Create a Stripe checkout session for an order.
Args:
order: Order object with items and total_amount
customer_email: Customer email for pre-filling in Stripe checkout
Returns:
Stripe checkout session URL
"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[STRIPE_DEBUG] Starting checkout session creation for order")
logger.info(f"[STRIPE_DEBUG] Order ID: {getattr(order, 'id', 'unknown')}")
logger.info(f"[STRIPE_DEBUG] Order items type: {type(order.items)}")
logger.info(f"[STRIPE_DEBUG] Order items: {order.items}")
logger.info(f"[STRIPE_DEBUG] Order total_amount: {order.total_amount}")
logger.info(f"[STRIPE_DEBUG] Customer email: {customer_email}")
try:
# Extract order items for Stripe line items
line_items = []
# Assuming order.items contains a list of menu items with details
# This would need to be parsed based on your order structure
if isinstance(order.items, list):
logger.info(f"[STRIPE_DEBUG] Processing {len(order.items)} order items")
for i, item in enumerate(order.items):
logger.info(f"[STRIPE_DEBUG] Processing item {i}: {item}")
# Check if item has menu_item_id (frontend format)
if 'menu_item_id' in item:
# Need to fetch menu item details from menu_data
try:
from menu_data import get_menu_item_by_id
menu_item = get_menu_item_by_id(item['menu_item_id'])
menu_item_name = menu_item.get('name', 'Menu Item')
# Price in menu_data is already in cents
menu_item_price = menu_item.get('price', order.total_amount)
logger.info(f"[STRIPE_DEBUG] Fetched menu item: {menu_item_name}, price (cents): {menu_item_price}")
except (ValueError, ImportError) as e:
logger.error(f"[STRIPE_DEBUG] Failed to fetch menu item {item.get('menu_item_id')}: {e}")
menu_item_name = f"Menu Item {item.get('menu_item_id', 'Unknown')}"
menu_item_price = order.total_amount
else:
# Assuming item has menu_item_id, name, price, and quantity (expected format)
menu_item_name = item.get('name', 'Menu Item')
menu_item_price = item.get('price', order.total_amount)
quantity = item.get('quantity', 1)
logger.info(f"[STRIPE_DEBUG] Item: {menu_item_name}, quantity: {quantity}, price: {menu_item_price}")
line_items.append({
'price_data': {
'currency': settings.stripe_default_currency, # Taiwan Dollar
'product_data': {
'name': menu_item_name,
},
'unit_amount': int(menu_item_price), # Amount in cents
},
'quantity': quantity,
})
else:
# Single item order - fallback
logger.warning(f"[STRIPE_DEBUG] Order items is not a list, using fallback single item")
line_items.append({
'price_data': {
'currency': settings.stripe_default_currency,
'product_data': {
'name': 'Silver Table Order',
},
'unit_amount': int(order.total_amount),
},
'quantity': 1,
})
logger.info(f"[STRIPE_DEBUG] Created {len(line_items)} line items for Stripe")
# Create Stripe checkout session
session_params = {
'payment_method_types': ['card'],
'line_items': line_items,
'mode': 'payment',
'success_url': f"{settings.frontend_url}/order/success?session_id={{CHECKOUT_SESSION_ID}}",
'cancel_url': f"{settings.frontend_url}/order/cancel",
'metadata': {
'order_id': str(order.id),
'profile_id': str(order.profile_id),
'order_type': 'food_order'
},
'shipping_address_collection': {
'allowed_countries': ['TW'], # Taiwan
},
'phone_number_collection': {
'enabled': True,
}
}
# Add customer_email if provided
if customer_email:
session_params['customer_email'] = customer_email
logger.info(f"[STRIPE_DEBUG] Added customer email to session")
logger.info(f"[STRIPE_DEBUG] Creating Stripe checkout session...")
session = stripe.checkout.Session.create(**session_params)
logger.info(f"[STRIPE_DEBUG] Stripe session created successfully: {session.id}")
return session.url
except Exception as e:
logger.error(f"[STRIPE_DEBUG] Failed to create checkout session: {str(e)}")
logger.error(f"[STRIPE_DEBUG] Error type: {type(e).__name__}")
import traceback
logger.error(f"[STRIPE_DEBUG] Traceback: {traceback.format_exc()}")
raise Exception(f"Failed to create checkout session: {str(e)}")
def create_checkout_session_for_donation(donation) -> str:
"""
Create a Stripe checkout session for a donation.
Args:
donation: Donation object with amount and donor_name
Returns:
Stripe checkout session URL
"""
try:
# Validate minimum donation amount for Stripe (donation.amount is in cents)
min_amount_cents = int(settings.MIN_DONATION_AMOUNT * 100)
if donation.amount < min_amount_cents:
raise PaymentException(f"捐款金額必須至少為 NT${settings.MIN_DONATION_AMOUNT} 元")
# Create Stripe checkout session for donation
session = stripe.checkout.Session.create(
payment_method_types=['card'],
line_items=[
{
'price_data': {
'currency': settings.stripe_default_currency, # Taiwan Dollar
'product_data': {
'name': '銀桌助手捐款',
'description': f'捐款人: {donation.donor_name or "匿名"}' if donation.donor_name else '匿名捐款',
},
'unit_amount': int(donation.amount), # Amount in cents
},
'quantity': 1,
}
],
mode='payment',
success_url=f"{settings.frontend_url}/donation/success?session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=f"{settings.frontend_url}/donation/cancel",
metadata={
'donation_id': str(donation.id),
'donor_name': donation.donor_name or 'anonymous',
'order_type': 'donation'
},
phone_number_collection={
'enabled': True,
}
)
return session.url
except stripe.error.InvalidRequestError as e:
# Stripe can return an amount_too_small error code
code = getattr(e, 'code', None)
if code == 'amount_too_small' or 'amount_too_small' in str(e):
raise PaymentException(f"金額太低,最低需 NT${settings.MIN_DONATION_AMOUNT}")
raise handle_payment_error(e, 'create_checkout_session_for_donation')
except Exception as e:
raise handle_payment_error(e, 'create_checkout_session_for_donation')
def handle_webhook(payload: bytes, sig_header: str) -> Dict[str, Any]:
"""
Handle Stripe webhook events.
Args:
payload: Raw webhook payload
sig_header: Stripe signature header
Returns:
Dictionary with event details and status
"""
try:
# Verify webhook signature
event = stripe.Webhook.construct_event(
payload, sig_header, settings.stripe_webhook_secret
)
# Handle the event
if event['type'] == 'checkout.session.completed':
session = event['data']['object']
return handle_checkout_session_completed(session)
elif event['type'] == 'payment_intent.succeeded':
payment_intent = event['data']['object']
return handle_payment_intent_succeeded(payment_intent)
elif event['type'] == 'payment_intent.payment_failed':
payment_intent = event['data']['object']
return handle_payment_intent_failed(payment_intent)
else:
return {
'status': 'ignored',
'message': f'Event type {event["type"]} not handled',
'event_id': event['id']
}
except stripe.error.SignatureVerificationError as e:
# Invalid signature - treat as payment related/security error
raise PaymentException(f"Invalid webhook signature: {str(e)}")
except Exception as e:
raise Exception(f"Webhook handling failed: {str(e)}")
def handle_checkout_session_completed(session: Dict[str, Any]) -> Dict[str, Any]:
"""
Handle successful checkout session completion.
Args:
session: Stripe checkout session object
Returns:
Dictionary with processing result
"""
metadata = session.get('metadata', {})
order_type = metadata.get('order_type')
session_id = session['id']
if order_type == 'food_order':
order_id = metadata.get('order_id')
if order_id:
# Update order status to confirmed
return update_order_payment_status(UUID(order_id), 'confirmed', session_id)
else:
return {
'status': 'error',
'message': 'Missing order_id in metadata',
'session_id': session_id
}
elif order_type == 'donation':
donation_id = metadata.get('donation_id')
if donation_id:
# Update donation status to completed
return update_donation_payment_status(UUID(donation_id), 'completed', session_id)
else:
return {
'status': 'error',
'message': 'Missing donation_id in metadata',
'session_id': session_id
}
else:
return {
'status': 'error',
'message': f'Unknown order_type: {order_type}',
'session_id': session_id
}
def handle_payment_intent_succeeded(payment_intent: Dict[str, Any]) -> Dict[str, Any]:
"""
Handle successful payment intent.
Args:
payment_intent: Stripe payment intent object
Returns:
Dictionary with processing result
"""
# This is a backup handler for payment_intent.succeeded events
# The main handling should be done in checkout.session.completed
return {
'status': 'processed',
'message': 'Payment intent succeeded - handled via checkout session',
'payment_intent_id': payment_intent['id']
}
def handle_payment_intent_failed(payment_intent: Dict[str, Any]) -> Dict[str, Any]:
"""
Handle failed payment intent.
Args:
payment_intent: Stripe payment intent object
Returns:
Dictionary with processing result
"""
# Handle failed payments - you might want to update order/donation status
return {
'status': 'failed',
'message': 'Payment failed',
'payment_intent_id': payment_intent['id'],
'last_payment_error': payment_intent.get('last_payment_error', {})
}
def update_order_payment_status(order_id: UUID, status: str, stripe_session_id: str) -> Dict[str, Any]:
"""
Update order payment status in database.
Args:
order_id: Order ID
status: New status
stripe_session_id: Stripe session ID
Returns:
Dictionary with update result
"""
# This function would be called from the webhook handler
# You would need to inject the database session here
# For now, we'll just return the intended update data
return {
'status': 'success',
'message': f'Order {order_id} status updated to {status}',
'order_id': str(order_id),
'stripe_session_id': stripe_session_id,
'new_status': status
}
def update_donation_payment_status(donation_id: UUID, status: str, stripe_session_id: str) -> Dict[str, Any]:
"""
Update donation payment status in database.
Args:
donation_id: Donation ID
status: New status
stripe_session_id: Stripe session ID
Returns:
Dictionary with update result
"""
# This function would be called from the webhook handler
# You would need to inject the database session here
# For now, we'll just return the intended update data
return {
'status': 'success',
'message': f'Donation {donation_id} status updated to {status}',
'donation_id': str(donation_id),
'stripe_session_id': stripe_session_id,
'new_status': status
}
def retrieve_payment_intent(payment_intent_id: str) -> Optional[Dict[str, Any]]:
"""
Retrieve payment intent details from Stripe.
Args:
payment_intent_id: Stripe payment intent ID
Returns:
Payment intent object or None if not found
"""
try:
return stripe.PaymentIntent.retrieve(payment_intent_id)
except stripe.error.InvalidRequestError:
return None
def create_refund(payment_intent_id: str, amount: Optional[int] = None) -> Dict[str, Any]:
"""
Create a refund for a payment.
Args:
payment_intent_id: Payment intent ID to refund
amount: Refund amount in cents (optional, defaults to full amount)
Returns:
Refund object
"""
try:
refund_data = {
'payment_intent': payment_intent_id
}
if amount:
refund_data['amount'] = amount
refund = stripe.Refund.create(**refund_data)
return {
'status': 'success',
'refund_id': refund['id'],
'amount': refund['amount'],
'status': refund['status']
}
except Exception as e:
return {
'status': 'error',
'message': f'Failed to create refund: {str(e)}'
}
def get_payment_methods(customer_id: str) -> Dict[str, Any]:
"""
Get saved payment methods for a customer.
Args:
customer_id: Stripe customer ID
Returns:
Dictionary with payment methods
"""
try:
payment_methods = stripe.PaymentMethod.list(
customer=customer_id,
type='card'
)
return {
'status': 'success',
'payment_methods': payment_methods['data']
}
except Exception as e:
return {
'status': 'error',
'message': f'Failed to retrieve payment methods: {str(e)}'
}
def construct_webhook_event(payload: bytes, sig_header: str, secret: str) -> Dict[str, Any]:
"""
Construct webhook event from payload and signature.
Args:
payload: Raw payload bytes
sig_header: Stripe signature header
secret: Webhook secret
Returns:
Constructed event object
"""
return stripe.Webhook.construct_event(payload, sig_header, secret)
# Utility functions for webhook handling
def parse_metadata(metadata: Dict[str, str]) -> Dict[str, Any]:
"""
Parse Stripe metadata and extract useful information.
Args:
metadata: Stripe metadata dictionary
Returns:
Parsed metadata with type conversion
"""
parsed = {}
for key, value in metadata.items():
if key.endswith('_id') and value:
try:
# Convert UUID strings back to UUID objects
parsed[key] = UUID(value)
except ValueError:
parsed[key] = value
else:
parsed[key] = value
return parsed
def format_twd_amount(cents: int) -> str:
"""
Format amount in cents to Taiwan Dollar string.
Args:
cents: Amount in cents
Returns:
Formatted amount string
"""
return settings.format_currency(cents)
def validate_twd_currency(amount: int) -> bool:
"""
Validate if amount is appropriate for Taiwan Dollar.
Args:
amount: Amount in cents
Returns:
True if amount is valid
"""
return settings.min_order_amount <= amount <= settings.max_order_amount