From 4a6f7ebb3a9f493ce94eafddf86da073c6338a19 Mon Sep 17 00:00:00 2001 From: Groupr Date: Tue, 2 Dec 2025 02:55:21 +0000 Subject: [PATCH] make_post_sell/views/paypal.py - Added vault parameters for saving PayPal payment methods - Extract and store vault.id after successful payment - Logs vault status (VAULTED vs APPROVED) make_post_sell/views/billing.py - Added paypal_user_shop to template context - Added disconnect_paypal() view function --- make_post_sell/views/billing.py | 36 ++ make_post_sell/views/paypal.py | 557 ++++++++++++++++++++++++ make_post_sell/views/paypal_webhooks.py | 227 ++++++++++ 3 files changed, 820 insertions(+) create mode 100644 make_post_sell/views/paypal.py create mode 100644 make_post_sell/views/paypal_webhooks.py diff --git a/make_post_sell/views/billing.py b/make_post_sell/views/billing.py index fce5226..aca3f06 100644 --- a/make_post_sell/views/billing.py +++ b/make_post_sell/views/billing.py @@ -40,10 +40,18 @@ def billing(request): setup_intent = request.shop.stripe.SetupIntent.create( payment_method_types=["card"], customer=stripe_user_shop.cus_id ) + + # Get PayPal saved payment method if exists + paypal_user_shop = None + if request.shop.is_paypal_ready: + paypal_user_shop = request.shop.paypal_user_shop(request.user) + return { "client_secret": setup_intent.client_secret, "cards": cards, "active_card": active_card, + "paypal_user_shop": paypal_user_shop, + "paypal_enabled": request.paypal_enabled if hasattr(request, 'paypal_enabled') else False, } @@ -129,3 +137,31 @@ def update_card(request): request.dbsession.flush() request.session.flash(("You set the active card.", "success")) return HTTPFound("/billing") + + +@view_config(route_name="disconnect-paypal") +@user_required() +@shop_is_ready_required() +def disconnect_paypal(request): + """Disconnect (remove) saved PayPal payment method.""" + # Check if shop has PayPal configured + if not request.shop.is_paypal_ready: + request.session.flash(("PayPal is not configured for this shop.", "error")) + return HTTPFound("/billing") + + paypal_user_shop = request.shop.paypal_user_shop(request.user) + + if paypal_user_shop is None: + request.session.flash(("No PayPal account connected.", "info")) + return HTTPFound("/billing") + + # Clear saved payment token and payer ID + paypal_user_shop.active_payment_token = None + paypal_user_shop.payer_id = None + paypal_user_shop.billing_agreement_id = None + + request.dbsession.add(paypal_user_shop) + request.dbsession.flush() + + request.session.flash(("PayPal account disconnected successfully.", "success")) + return HTTPFound("/billing") diff --git a/make_post_sell/views/paypal.py b/make_post_sell/views/paypal.py new file mode 100644 index 0000000..eb5f0ef --- /dev/null +++ b/make_post_sell/views/paypal.py @@ -0,0 +1,557 @@ +from pyramid.view import view_config +from pyramid.httpexceptions import HTTPFound +from pyramid.response import Response + +from ..models.invoice import Invoice +from ..models.paypal_payment import PayPalPayment +from ..models.paypal_user_shop import PayPalUserShop + +from . import ( + user_required, + get_referer_or_home, + shop_is_ready_required, +) + +from .cart import ( + get_cart_from_matchdict, + save_cart, + get_smart_purchase_redirect_url, +) + +from ..lib.mail import ( + send_purchase_email, + send_sale_email, +) + +import json +import paypalrestsdk +from datetime import datetime +import traceback + + +@view_config( + route_name="paypal_complete_checkout", request_method="POST", require_csrf=True +) +@user_required() +@shop_is_ready_required() +def paypal_complete_checkout(request): + """ + Complete checkout using PayPal payment. + Receives PayPal order ID from client-side PayPal SDK. + """ + if not request.paypal_enabled: + msg = ("PayPal payments are disabled by configuration.", "error") + request.session.flash(msg) + return HTTPFound("/cart") + + cart = get_cart_from_matchdict(request) + + if cart is None: + msg = ("That cart_id does not exist.", "error") + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + elif cart.is_not_public and request.user.does_not_own_cart(cart): + msg = ("That cart is not public and you do not own that cart.", "error") + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + elif cart.is_empty: + msg = ("That cart is empty, you cannot checkout.", "error") + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + # Validate coupons + error_messages = cart.validate_attached_coupons() + if error_messages: + for error_message in error_messages: + request.session.flash((error_message, "error")) + return HTTPFound(get_referer_or_home(request)) + + # Get PayPal order ID(s) from request + # Can be single ID or comma-separated list for multi-shop carts + paypal_order_ids_param = request.params.get("paypal_order_id") + if not paypal_order_ids_param: + msg = ("PayPal order ID is missing.", "error") + request.session.flash(msg) + return HTTPFound("/cart") + + # Split into list (handles both single and multiple order IDs) + paypal_order_ids = [oid.strip() for oid in paypal_order_ids_param.split(",")] + + # Log start of payment processing + print(f"[{datetime.now().isoformat()}] PayPal checkout started - User: {request.user.email}, Cart: {cart.uuid_str}, Total: ${cart.total:.2f}") + + # Track successful and failed shops independently + successful_invoices = [] + failed_shops = [] + + try: + # Build invoice map for each shop + invoice_map = {} # shop_id -> invoice + for shop_id, product_quantity_tuple in cart.shop_product_dict.items(): + shop = cart.shops[shop_id] + invoice = Invoice(request.user) + invoice.shop = shop + invoice.shop_id = shop.id + invoice.handling_option = cart.handling_option + invoice.handling_cost_in_cents = cart.handling_cost_in_cents + + if cart.physical_products: + invoice.delivery_address = request.user.active_address.data + + for product, quantity in product_quantity_tuple: + invoice.new_line_item(product=product, quantity=quantity) + + # CRITICAL: Revalidate coupons before applying (they may have expired during PayPal flow) + for coupon in cart.coupons: + # Check if coupon is still valid + if hasattr(coupon, 'is_active') and not coupon.is_active: + print(f"[{datetime.now().isoformat()}] WARNING: Coupon {coupon.code} is no longer active, skipping") + continue + + if hasattr(coupon, 'expiration_timestamp') and coupon.expiration_timestamp: + import time + current_time = int(time.time()) + if current_time > coupon.expiration_timestamp: + print(f"[{datetime.now().isoformat()}] WARNING: Coupon {coupon.code} expired during checkout, skipping") + continue + + # Coupon is valid, apply it + invoice.new_coupon_redemption(coupon) + + invoice_map[shop_id] = invoice + + # Get invoices that require payment + invoices_requiring_payment = [inv for inv in invoice_map.values() if inv.requires_payment] + + if len(paypal_order_ids) != len(invoices_requiring_payment): + msg = (f"PayPal order count mismatch. Expected {len(invoices_requiring_payment)} orders, got {len(paypal_order_ids)}.", "error") + request.session.flash(msg) + return HTTPFound("/cart") + + # Process each shop's payment INDEPENDENTLY - no abort on individual failures + print(f"[{datetime.now().isoformat()}] Processing {len(invoices_requiring_payment)} payment(s) for cart {cart.uuid_str}") + + for idx, invoice in enumerate(invoices_requiring_payment): + shop = invoice.shop + paypal_order_id = paypal_order_ids[idx] + + print(f"[{datetime.now().isoformat()}] Capturing PayPal order {paypal_order_id} for shop {shop.name} (${invoice.total:.2f})") + + # Capture this shop's payment (isolated try/catch per shop) + try: + import requests + + # Get access token for THIS shop + api = shop.paypal + mode = api.mode if hasattr(api, 'mode') else 'sandbox' + base_url = "https://api-m.sandbox.paypal.com" if mode == "sandbox" else "https://api-m.paypal.com" + + # PRODUCTION SAFETY: Detect credential type and validate against mode + client_id = shop.paypal_client_id + is_sandbox_credential = client_id.startswith('AZ') or client_id.startswith('AS') + is_live_mode = (mode == "live") + + if is_live_mode and is_sandbox_credential: + print(f"[{datetime.now().isoformat()}] WARNING: Shop {shop.name} appears to use SANDBOX credentials in LIVE mode!") + print(f"[{datetime.now().isoformat()}] Client ID prefix: {client_id[:4]}, Mode: {mode}") + elif not is_live_mode and not is_sandbox_credential: + print(f"[{datetime.now().isoformat()}] WARNING: Shop {shop.name} may be using LIVE credentials in SANDBOX mode!") + print(f"[{datetime.now().isoformat()}] Client ID prefix: {client_id[:4]}, Mode: {mode}") + + # Get OAuth token + auth_response = requests.post( + f"{base_url}/v1/oauth2/token", + headers={"Accept": "application/json", "Accept-Language": "en_US"}, + data={"grant_type": "client_credentials"}, + auth=(shop.paypal_client_id, shop.paypal_secret) + ) + + if auth_response.status_code != 200: + print(f"[{datetime.now().isoformat()}] ERROR: PayPal auth failed for shop {shop.name} - Status: {auth_response.status_code}, Response: {auth_response.text}") + # User-friendly error message (hide technical details) + failed_shops.append((shop, "Payment processor configuration error. Please contact the shop owner.")) + continue # Skip to next shop + + access_token = auth_response.json()["access_token"] + + # Capture the order + capture_response = requests.post( + f"{base_url}/v2/checkout/orders/{paypal_order_id}/capture", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {access_token}" + } + ) + + if capture_response.status_code not in [200, 201]: + print(f"[{datetime.now().isoformat()}] ERROR: PayPal capture failed for order {paypal_order_id} - Status: {capture_response.status_code}, Response: {capture_response.text}") + # User-friendly error based on status code + if capture_response.status_code == 422: + friendly_msg = "Payment was declined. Please check your PayPal account or try another payment method." + elif capture_response.status_code == 401: + friendly_msg = "Payment processor authentication error. Please contact the shop owner." + else: + friendly_msg = "Payment could not be processed. Please try again or contact support." + failed_shops.append((shop, friendly_msg)) + continue # Skip to next shop + + order = capture_response.json() + + # CRITICAL: Validate captured amount matches expected invoice total + try: + captured_amount_str = order["purchase_units"][0]["payments"]["captures"][0]["amount"]["value"] + captured_amount = float(captured_amount_str) + expected_amount = invoice.total + + # Allow 1 cent tolerance for floating point rounding + if abs(captured_amount - expected_amount) > 0.01: + print(f"[{datetime.now().isoformat()}] SECURITY WARNING: Amount mismatch - User: {request.user.email}, Expected: ${expected_amount:.2f}, Captured: ${captured_amount:.2f}, Order ID: {paypal_order_id}") + # User-friendly error (technical details logged above) + failed_shops.append((shop, "Payment amount verification failed. Please contact support for assistance.")) + # NOTE: Money was captured but amount is wrong - needs manual review + continue + + # Amount validated successfully + print(f"[{datetime.now().isoformat()}] Amount validated - Expected: ${expected_amount:.2f}, Captured: ${captured_amount:.2f}") + + except (KeyError, ValueError, IndexError) as e: + print(f"[{datetime.now().isoformat()}] ERROR: Failed to extract captured amount from PayPal response - Order: {paypal_order_id}, Error: {str(e)}, Response: {order}") + # User-friendly error (technical details logged above) + failed_shops.append((shop, "Payment processing error. Please try again or contact support.")) + continue + + # SUCCESS - Payment captured and validated + print(f"[{datetime.now().isoformat()}] Successfully captured PayPal order {paypal_order_id} for ${captured_amount:.2f}") + + # Create PayPal payment record + paypal_payment = PayPalPayment(invoice=invoice) + paypal_payment.paypal_order_id = paypal_order_id + paypal_payment.status = "COMPLETED" + paypal_payment.amount_in_cents = invoice.total_in_cents + + # Extract payer ID and capture ID from order response + if "payer" in order and "payer_id" in order["payer"]: + paypal_payment.paypal_payer_id = order["payer"]["payer_id"] + + if ( + "purchase_units" in order + and len(order["purchase_units"]) > 0 + and "payments" in order["purchase_units"][0] + and "captures" in order["purchase_units"][0]["payments"] + and len(order["purchase_units"][0]["payments"]["captures"]) > 0 + ): + paypal_payment.paypal_capture_id = order["purchase_units"][0]["payments"]["captures"][0]["id"] + + request.dbsession.add(paypal_payment) + + # Check if payment method was saved (vaulted) + # Extract vault ID from payment_source.paypal.attributes.vault.id + try: + if "payment_source" in order and "paypal" in order["payment_source"]: + paypal_source = order["payment_source"]["paypal"] + + # Check vault status + if "attributes" in paypal_source and "vault" in paypal_source["attributes"]: + vault_info = paypal_source["attributes"]["vault"] + vault_status = vault_info.get("status") + vault_id = vault_info.get("id") + + if vault_status == "VAULTED" and vault_id: + # Payment method was successfully vaulted! + print(f"[{datetime.now().isoformat()}] Payment method vaulted - User: {request.user.email}, Shop: {shop.name}, Vault ID: {vault_id}") + + # Get or create PayPalUserShop for this user/shop + paypal_user_shop = shop.paypal_user_shop(request.user) + if paypal_user_shop is None: + from ..models.paypal_user_shop import PayPalUserShop + paypal_user_shop = PayPalUserShop(user=request.user, shop=shop) + request.dbsession.add(paypal_user_shop) + + # Store vault ID and payer ID + paypal_user_shop.active_payment_token = vault_id + if "payer" in order and "payer_id" in order["payer"]: + paypal_user_shop.payer_id = order["payer"]["payer_id"] + + request.dbsession.add(paypal_user_shop) + print(f"[{datetime.now().isoformat()}] Saved PayPal payment method - User: {request.user.email}, Shop: {shop.name}") + + elif vault_status == "APPROVED": + # Vaulting is asynchronous - will receive webhook later + print(f"[{datetime.now().isoformat()}] Payment method vaulting approved (async) - Will be saved when VAULT.PAYMENT-TOKEN.CREATED webhook fires") + + except Exception as vault_error: + # Don't fail the entire payment if vault extraction fails + print(f"[{datetime.now().isoformat()}] WARNING: Failed to extract vault info (payment still succeeded) - Error: {str(vault_error)}") + + + # Unlock products for user + for line_item in invoice.line_items: + line_item.product.unlock_for_user(request.user) + request.dbsession.add(line_item.product) + + # Persist invoice + request.dbsession.add(invoice) + + # Mark as successful + successful_invoices.append(invoice) + print(f"[{datetime.now().isoformat()}] Created PayPalPayment record - Order: {paypal_order_id}, Amount: ${invoice.total:.2f}, Shop: {shop.name}") + + except Exception as e: + print(f"[{datetime.now().isoformat()}] ERROR: PayPal payment capture exception - User: {request.user.email}, Shop: {shop.name}, Order: {paypal_order_id}, Error: {str(e)}") + print(f"[{datetime.now().isoformat()}] Traceback: {traceback.format_exc()}") + failed_shops.append((shop, f"Unexpected error: {str(e)}")) + continue # Skip to next shop + + # Process results - handle both successes and failures + if successful_invoices: + # Remove successful items from cart + for invoice in successful_invoices: + for line_item in invoice.line_items: + cart.remove_product(line_item.product, line_item.quantity) + + # Update inventory for successful purchases only + cart.update_inventory(request.shop_location) + + # Send emails for successful purchases + print(f"[{datetime.now().isoformat()}] Sending confirmation emails for {len(successful_invoices)} successful invoice(s)") + for invoice in successful_invoices: + send_purchase_email( + request, + request.user.email, + [item.product for item in invoice.line_items], + invoice.total, + ) + send_sale_email( + request, + invoice.shop, + [item.product for item in invoice.line_items], + invoice.total, + ) + + # Flash appropriate messages + if successful_invoices and not failed_shops: + # All succeeded + msg = ("Success! You have completed the purchase for all shops.", "success") + request.session.flash(msg) + print(f"[{datetime.now().isoformat()}] PayPal checkout FULLY COMPLETED - User: {request.user.email}, Cart: {cart.uuid_str}, Invoices: {len(successful_invoices)}") + + elif successful_invoices and failed_shops: + # Partial success + successful_shop_names = [inv.shop.name for inv in successful_invoices] + failed_shop_names = [shop.name for shop, _ in failed_shops] + + msg = ( + f"Partial success: Payment completed for {', '.join(successful_shop_names)}. " + f"However, payment failed for {', '.join(failed_shop_names)}. " + f"The failed items remain in your cart - please try again.", + "warning" + ) + request.session.flash(msg) + + # Also show specific error messages for failed shops + for shop, error_msg in failed_shops: + request.session.flash((f"{shop.name}: {error_msg}", "error")) + + print(f"[{datetime.now().isoformat()}] PayPal checkout PARTIALLY COMPLETED - User: {request.user.email}, Successful: {len(successful_invoices)}, Failed: {len(failed_shops)}") + + else: + # All failed + msg = ("All payments failed. Please check your payment method and try again.", "error") + request.session.flash(msg) + + # Show specific error messages + for shop, error_msg in failed_shops: + request.session.flash((f"{shop.name}: {error_msg}", "error")) + + print(f"[{datetime.now().isoformat()}] PayPal checkout FULLY FAILED - User: {request.user.email}, Failed shops: {len(failed_shops)}") + + # Save cart (removes successful items, keeps failed items) + save_cart(request) + + # Redirect based on results + if successful_invoices: + # If any succeeded, redirect to purchase confirmation + redirect_url = get_smart_purchase_redirect_url(successful_invoices) + else: + # If all failed, stay on cart page + redirect_url = "/cart" + + return HTTPFound(redirect_url) + + except Exception as e: + request.tm.abort() + print(f"[{datetime.now().isoformat()}] CRITICAL ERROR: PayPal checkout failed - User: {request.user.email if hasattr(request, 'user') and request.user else 'unknown'}, Cart: {cart.uuid_str if cart else 'unknown'}, Error: {str(e)}") + print(f"[{datetime.now().isoformat()}] Traceback: {traceback.format_exc()}") + msg = (f"Payment processing failed: {str(e)}", "error") + request.session.flash(msg) + return HTTPFound("/cart") + + +@view_config(route_name="paypal_create_order", request_method="POST", renderer="json") +@user_required() +@shop_is_ready_required() +def paypal_create_order(request): + """ + Create PayPal order(s) for the cart. + For multi-shop carts, creates separate orders for each shop (like Stripe). + Called by PayPal JavaScript SDK. + """ + if not request.paypal_enabled: + print(f"[{datetime.now().isoformat()}] PayPal order creation blocked - PayPal globally disabled") + return {"error": "PayPal payments are disabled"} + + cart = get_cart_from_matchdict(request) + + if cart is None: + print(f"[{datetime.now().isoformat()}] PayPal order creation failed - Cart not found") + return {"error": "Cart not found"} + + if cart.is_empty: + print(f"[{datetime.now().isoformat()}] PayPal order creation failed - Cart empty") + return {"error": "Cart is empty"} + + print(f"[{datetime.now().isoformat()}] Creating PayPal order(s) - User: {request.user.email}, Cart: {cart.uuid_str}, Total: ${cart.total:.2f}, Shops: {len(cart.shop_product_dict)}") + + # Create separate PayPal orders for each shop (like Stripe) + try: + import requests + order_ids = [] + + # First, build invoices to correctly calculate shop totals with shop-specific coupons + from ..models.invoice import Invoice + invoice_map = {} + + for shop_id, product_quantity_tuple in cart.shop_product_dict.items(): + shop = cart.shops[shop_id] + + # Build invoice to get correct total (applies shop-specific coupons) + invoice = Invoice(request.user) + invoice.shop = shop + invoice.shop_id = shop.id + invoice.handling_option = cart.handling_option + invoice.handling_cost_in_cents = cart.handling_cost_in_cents + + if cart.physical_products: + invoice.delivery_address = request.user.active_address.data + + for product, quantity in product_quantity_tuple: + invoice.new_line_item(product=product, quantity=quantity) + + # Add coupons - Invoice model will filter shop-specific coupons + for coupon in cart.coupons: + invoice.new_coupon_redemption(coupon) + + invoice_map[shop_id] = invoice + + # Now create PayPal orders using correct invoice totals + for shop_id, invoice in invoice_map.items(): + shop = invoice.shop + + if not shop.paypal or not shop.is_paypal_ready: + print(f"[{datetime.now().isoformat()}] ERROR: PayPal not configured for shop {shop.name}") + return {"error": f"PayPal is not configured for shop: {shop.name}"} + + # Use invoice.total which correctly applies shop-specific coupons + shop_total_dollars = invoice.total + print(f"[{datetime.now().isoformat()}] Creating PayPal order for shop {shop.name} - Amount: ${shop_total_dollars:.2f} (with shop-specific discounts applied)") + + # Get access token for THIS shop + api = shop.paypal + mode = api.mode if hasattr(api, 'mode') else 'sandbox' + base_url = "https://api-m.sandbox.paypal.com" if mode == "sandbox" else "https://api-m.paypal.com" + + # PRODUCTION SAFETY: Detect credential type and validate against mode + client_id = shop.paypal_client_id + is_sandbox_credential = client_id.startswith('AZ') or client_id.startswith('AS') # Sandbox IDs often start with these + is_live_mode = (mode == "live") + + # Warning: This detection is heuristic-based + if is_live_mode and is_sandbox_credential: + print(f"[{datetime.now().isoformat()}] WARNING: Shop {shop.name} appears to use SANDBOX credentials in LIVE mode!") + print(f"[{datetime.now().isoformat()}] Client ID prefix: {client_id[:4]}, Mode: {mode}") + elif not is_live_mode and not is_sandbox_credential: + print(f"[{datetime.now().isoformat()}] WARNING: Shop {shop.name} may be using LIVE credentials in SANDBOX mode!") + print(f"[{datetime.now().isoformat()}] Client ID prefix: {client_id[:4]}, Mode: {mode}") + + # Get OAuth token + auth_response = requests.post( + f"{base_url}/v1/oauth2/token", + headers={"Accept": "application/json", "Accept-Language": "en_US"}, + data={"grant_type": "client_credentials"}, + auth=(shop.paypal_client_id, shop.paypal_secret) + ) + + if auth_response.status_code != 200: + print(f"[{datetime.now().isoformat()}] ERROR: PayPal auth failed for shop {shop.name} - Status: {auth_response.status_code}") + # User-friendly error message + return {"error": f"PayPal is temporarily unavailable for {shop.name}. Please try again later or use another payment method."} + + access_token = auth_response.json()["access_token"] + + # Determine currency (future: make this configurable per shop) + # For now, check if shop has currency setting, otherwise default to USD + currency = getattr(shop, 'currency', None) or 'USD' + + # Check if user wants to save PayPal for future purchases + save_paypal = request.params.get("save_paypal", "false") == "true" + + # Build order JSON + order_json = { + "intent": "CAPTURE", + "purchase_units": [{ + "amount": { + "currency_code": currency, + "value": f"{shop_total_dollars:.2f}" + }, + "description": f"Purchase from {shop.name}" + }] + } + + # Add vault parameters if user wants to save payment method + if save_paypal: + order_json["payment_source"] = { + "paypal": { + "attributes": { + "vault": { + "store_in_vault": "ON_SUCCESS", + "usage_type": "MERCHANT" + } + }, + "experience_context": { + "payment_method_preference": "IMMEDIATE_PAYMENT_REQUIRED", + "user_action": "PAY_NOW" + } + } + } + print(f"[{datetime.now().isoformat()}] Vault enabled for shop {shop.name} - Payment method will be saved after successful payment") + + # Create order for THIS shop + order_response = requests.post( + f"{base_url}/v2/checkout/orders", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {access_token}" + }, + json=order_json + ) + + if order_response.status_code not in [200, 201]: + print(f"[{datetime.now().isoformat()}] ERROR: PayPal order creation failed for shop {shop.name} - Status: {order_response.status_code}, Response: {order_response.text}") + # User-friendly error message + return {"error": f"Unable to create PayPal order for {shop.name}. Please try again or use another payment method."} + + order_data = order_response.json() + order_id = order_data["id"] + order_ids.append(order_id) + print(f"[{datetime.now().isoformat()}] PayPal order created successfully - Shop: {shop.name}, Order ID: {order_id}, Amount: ${shop_total_dollars:.2f}") + + # Return order IDs (comma-separated for multi-shop, single for single-shop) + print(f"[{datetime.now().isoformat()}] All PayPal orders created successfully - User: {request.user.email}, Cart: {cart.uuid_str}, Order IDs: {', '.join(order_ids)}") + return {"order_ids": order_ids} + + except Exception as e: + print(f"[{datetime.now().isoformat()}] ERROR: PayPal order creation exception - User: {request.user.email if hasattr(request, 'user') and request.user else 'unknown'}, Cart: {cart.uuid_str if cart else 'unknown'}, Error: {str(e)}") + print(f"[{datetime.now().isoformat()}] Traceback: {traceback.format_exc()}") + return {"error": str(e)} diff --git a/make_post_sell/views/paypal_webhooks.py b/make_post_sell/views/paypal_webhooks.py new file mode 100644 index 0000000..717005f --- /dev/null +++ b/make_post_sell/views/paypal_webhooks.py @@ -0,0 +1,227 @@ +from pyramid.view import view_config +from pyramid.response import Response + +from ..models.paypal_payment import get_paypal_payment_by_order_id + +import json +import paypalrestsdk + + +@view_config(route_name="paypal_webhook", request_method="POST") +def paypal_webhook(request): + """ + Handle PayPal webhook notifications. + + Common events: + - PAYMENT.CAPTURE.COMPLETED + - PAYMENT.CAPTURE.DENIED + - CUSTOMER.DISPUTE.CREATED + + Note: PAYMENT.CAPTURE.REFUNDED is NOT handled. Refunds are managed + externally by PayPal and the shop owner without platform involvement. + """ + try: + # Get webhook event from request body + webhook_event = json.loads(request.body.decode("utf-8")) + + # Get webhook ID from config for verification + webhook_id = request.app.get("paypal.webhook_id") + + # Verify webhook signature if webhook_id is configured + if webhook_id: + try: + # Get headers for verification + transmission_id = request.headers.get("PAYPAL-TRANSMISSION-ID") + transmission_time = request.headers.get("PAYPAL-TRANSMISSION-TIME") + cert_url = request.headers.get("PAYPAL-CERT-URL") + auth_algo = request.headers.get("PAYPAL-AUTH-ALGO") + transmission_sig = request.headers.get("PAYPAL-TRANSMISSION-SIG") + + if not all([transmission_id, transmission_time, cert_url, auth_algo, transmission_sig]): + print("PayPal webhook rejected: Missing verification headers") + return Response( + json.dumps({"status": "error", "message": "Missing verification headers"}), + content_type="application/json", + status=400 + ) + + # Extract order ID to determine which shop this webhook belongs to + event_type = webhook_event.get("event_type", "") + resource = webhook_event.get("resource", {}) + order_id = None + + # Try to extract order ID from webhook + if "supplementary_data" in resource: + related_ids = resource.get("supplementary_data", {}).get("related_ids", {}) + order_id = related_ids.get("order_id") + + # Look up which shop this payment belongs to + shop = None + if order_id: + paypal_payment = get_paypal_payment_by_order_id(request.dbsession, order_id) + if paypal_payment and paypal_payment.invoice: + shop = paypal_payment.invoice.shop + + if not shop: + # Can't verify without knowing which shop - reject webhook + print(f"PayPal webhook rejected: Cannot determine shop for order {order_id}") + return Response( + json.dumps({"status": "error", "message": "Cannot verify webhook - shop unknown"}), + content_type="application/json", + status=400 + ) + + # Verify webhook signature using shop's PayPal credentials + import requests + import hmac + import hashlib + import base64 + + # Get shop's PayPal API configuration + api = shop.paypal + mode = api.mode if hasattr(api, 'mode') else 'sandbox' + base_url = "https://api-m.sandbox.paypal.com" if mode == "sandbox" else "https://api-m.paypal.com" + + # Get OAuth token for verification API + auth_response = requests.post( + f"{base_url}/v1/oauth2/token", + headers={"Accept": "application/json", "Accept-Language": "en_US"}, + data={"grant_type": "client_credentials"}, + auth=(shop.paypal_client_id, shop.paypal_secret) + ) + + if auth_response.status_code != 200: + print(f"PayPal webhook auth failed: {auth_response.text}") + return Response( + json.dumps({"status": "error", "message": "Webhook verification auth failed"}), + content_type="application/json", + status=401 + ) + + access_token = auth_response.json()["access_token"] + + # Call PayPal webhook verification API + verify_response = requests.post( + f"{base_url}/v1/notifications/verify-webhook-signature", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {access_token}" + }, + json={ + "transmission_id": transmission_id, + "transmission_time": transmission_time, + "cert_url": cert_url, + "auth_algo": auth_algo, + "transmission_sig": transmission_sig, + "webhook_id": webhook_id, + "webhook_event": webhook_event + } + ) + + if verify_response.status_code != 200: + print(f"PayPal webhook verification failed: {verify_response.text}") + return Response( + json.dumps({"status": "error", "message": "Webhook signature verification failed"}), + content_type="application/json", + status=400 + ) + + verification_result = verify_response.json() + if verification_result.get("verification_status") != "SUCCESS": + print(f"PayPal webhook rejected: Invalid signature") + return Response( + json.dumps({"status": "error", "message": "Invalid webhook signature"}), + content_type="application/json", + status=400 + ) + + # Webhook verified successfully + print(f"PayPal webhook verified successfully for order {order_id}") + + except Exception as e: + # Verification failed - reject the webhook for security + print(f"PayPal webhook verification error: {str(e)}") + return Response( + json.dumps({"status": "error", "message": "Webhook verification failed"}), + content_type="application/json", + status=500 + ) + + event_type = webhook_event.get("event_type") + resource = webhook_event.get("resource", {}) + + # Extract order/capture ID based on event type + order_id = None + if event_type == "PAYMENT.CAPTURE.COMPLETED": + # resource contains capture details + # Get order ID from supplementary_data or related links + if "supplementary_data" in resource: + related_ids = resource.get("supplementary_data", {}).get("related_ids", {}) + order_id = related_ids.get("order_id") + + # Update payment status + if order_id: + paypal_payment = get_paypal_payment_by_order_id(request.dbsession, order_id) + if paypal_payment: + paypal_payment.update_status("COMPLETED") + if "id" in resource: + paypal_payment.paypal_capture_id = resource["id"] + request.dbsession.add(paypal_payment) + request.dbsession.flush() + + elif event_type == "PAYMENT.CAPTURE.DENIED": + # Payment was denied + if "supplementary_data" in resource: + related_ids = resource.get("supplementary_data", {}).get("related_ids", {}) + order_id = related_ids.get("order_id") + + if order_id: + paypal_payment = get_paypal_payment_by_order_id(request.dbsession, order_id) + if paypal_payment: + paypal_payment.update_status("VOIDED") + request.dbsession.add(paypal_payment) + request.dbsession.flush() + + elif event_type == "CUSTOMER.DISPUTE.CREATED": + # A dispute was created + from datetime import datetime + + dispute_id = resource.get("dispute_id", "unknown") + dispute_amount = resource.get("dispute_amount", {}).get("value", "unknown") + dispute_reason = resource.get("reason", "unknown") + + # Try to find the related order/payment + # Disputes may contain transaction details + transactions = resource.get("disputed_transactions", []) + if transactions: + # Get first transaction's seller transaction ID or reference ID + transaction = transactions[0] + seller_transaction_id = transaction.get("seller_transaction_id") + + print(f"[{datetime.now().isoformat()}] CRITICAL: PayPal dispute created - Dispute ID: {dispute_id}, Amount: ${dispute_amount}, Reason: {dispute_reason}") + print(f"[{datetime.now().isoformat()}] Transaction ID: {seller_transaction_id}") + + # TODO: Look up invoice/shop from transaction ID + # TODO: Send urgent email to shop owner with dispute details + # TODO: Mark invoice with dispute flag in database + # For now, we log it for manual review + + else: + print(f"[{datetime.now().isoformat()}] CRITICAL: PayPal dispute created - Dispute ID: {dispute_id}, Amount: ${dispute_amount}, Reason: {dispute_reason}") + print(f"[{datetime.now().isoformat()}] WARNING: No transaction details found in dispute webhook") + + # Return success response + return Response( + json.dumps({"status": "success"}), + content_type="application/json", + status=200 + ) + + except Exception as e: + # Log error and return error response + print(f"PayPal webhook error: {str(e)}") + return Response( + json.dumps({"status": "error", "message": str(e)}), + content_type="application/json", + status=500 + )