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:
parent
b3270eb369
commit
4a6f7ebb3a
3 changed files with 820 additions and 0 deletions
|
|
@ -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")
|
||||
|
|
|
|||
557
make_post_sell/views/paypal.py
Normal file
557
make_post_sell/views/paypal.py
Normal 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)}
|
||||
227
make_post_sell/views/paypal_webhooks.py
Normal file
227
make_post_sell/views/paypal_webhooks.py
Normal 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
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue