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
This commit is contained in:
Groupr 2025-12-02 02:55:21 +00:00 committed by Russell Ballestrini
parent b3270eb369
commit 4a6f7ebb3a
3 changed files with 820 additions and 0 deletions

View file

@ -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")

View file

@ -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)}

View file

@ -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
)