| """ |
| 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 |
|
|
| |
| 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: |
| |
| line_items = [] |
| |
| |
| |
| 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}") |
| |
| if 'menu_item_id' in item: |
| |
| 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') |
| |
| 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: |
| |
| 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, |
| 'product_data': { |
| 'name': menu_item_name, |
| }, |
| 'unit_amount': int(menu_item_price), |
| }, |
| 'quantity': quantity, |
| }) |
| else: |
| |
| 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") |
| |
| |
| 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'], |
| }, |
| 'phone_number_collection': { |
| 'enabled': True, |
| } |
| } |
| |
| |
| 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: |
| |
| min_amount_cents = int(settings.MIN_DONATION_AMOUNT * 100) |
| if donation.amount < min_amount_cents: |
| raise PaymentException(f"捐款金額必須至少為 NT${settings.MIN_DONATION_AMOUNT} 元") |
| |
| |
| session = stripe.checkout.Session.create( |
| payment_method_types=['card'], |
| line_items=[ |
| { |
| 'price_data': { |
| 'currency': settings.stripe_default_currency, |
| 'product_data': { |
| 'name': '銀桌助手捐款', |
| 'description': f'捐款人: {donation.donor_name or "匿名"}' if donation.donor_name else '匿名捐款', |
| }, |
| 'unit_amount': int(donation.amount), |
| }, |
| '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: |
| |
| 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: |
| |
| event = stripe.Webhook.construct_event( |
| payload, sig_header, settings.stripe_webhook_secret |
| ) |
| |
| |
| 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: |
| |
| 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: |
| |
| 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: |
| |
| 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 |
| """ |
| |
| |
| |
| 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 |
| """ |
| |
| 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 |
| """ |
| |
| |
| |
| |
| 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 |
| """ |
| |
| |
| |
| |
| 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) |
|
|
|
|
| |
|
|
| 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: |
| |
| 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 |