Merge PayPal views into cart.py, delete paypal.py

This commit is contained in:
Russell Ballestrini 2025-12-22 14:29:00 -05:00
parent 8121772233
commit 979c5117df
3 changed files with 322 additions and 552 deletions

View file

@ -33,11 +33,11 @@ export MPS_PAYPAL_SANDBOX_MODE=True
- `Invoice.payment_method` returns "paypal" for PayPal payments
- Saved payment methods stored in `PayPalUserShop`
## Routes
## Code
- `POST /paypal/create-order/{cart_id}` - Creates PayPal order (called by JS SDK)
- `POST /paypal/complete-checkout/{cart_id}` - Captures payment, creates invoice
- `POST /webhooks/paypal` - Webhook handler for payment events
- `make_post_sell/views/cart.py` - PayPal checkout functions (`paypal_create_order`, `paypal_complete_checkout`)
- `make_post_sell/views/billing.py` - Disconnect saved PayPal
- `make_post_sell/views/paypal_webhooks.py` - Webhook handler
## Database

View file

@ -18,6 +18,8 @@ from ..lib.mail import (
)
import stripe
import traceback
from datetime import datetime
def get_cart_from_matchdict(request):
@ -729,3 +731,319 @@ def cart_complete_checkout(request):
msg = (f"Payment failed: {str(e)}", "error")
request.session.flash(msg)
return HTTPFound("/billing")
@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."""
if not request.paypal_enabled:
request.session.flash(("PayPal payments are disabled.", "error"))
return HTTPFound("/cart")
cart = get_cart_from_matchdict(request)
if cart is None:
request.session.flash(("That cart_id does not exist.", "error"))
return HTTPFound(get_referer_or_home(request))
elif cart.is_not_public and request.user.does_not_own_cart(cart):
request.session.flash(("That cart is not public and you do not own that cart.", "error"))
return HTTPFound(get_referer_or_home(request))
elif cart.is_empty:
request.session.flash(("That cart is empty, you cannot checkout.", "error"))
return HTTPFound(get_referer_or_home(request))
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))
paypal_order_ids_param = request.params.get("paypal_order_id")
if not paypal_order_ids_param:
request.session.flash(("PayPal order ID is missing.", "error"))
return HTTPFound("/cart")
paypal_order_ids = [oid.strip() for oid in paypal_order_ids_param.split(",")]
successful_invoices = []
failed_shops = []
try:
import requests
invoice_map = {}
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)
for coupon in cart.coupons:
if hasattr(coupon, 'is_active') and not coupon.is_active:
continue
invoice.new_coupon_redemption(coupon)
invoice_map[shop_id] = invoice
invoices_requiring_payment = [inv for inv in invoice_map.values() if inv.requires_payment]
if len(paypal_order_ids) != len(invoices_requiring_payment):
request.session.flash((f"PayPal order count mismatch.", "error"))
return HTTPFound("/cart")
for idx, invoice in enumerate(invoices_requiring_payment):
shop = invoice.shop
paypal_order_id = paypal_order_ids[idx]
try:
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"
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:
failed_shops.append((shop, "Payment processor configuration error."))
continue
access_token = auth_response.json()["access_token"]
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]:
failed_shops.append((shop, "Payment could not be processed."))
continue
order = capture_response.json()
try:
captured_amount = float(order["purchase_units"][0]["payments"]["captures"][0]["amount"]["value"])
if abs(captured_amount - invoice.total) > 0.01:
failed_shops.append((shop, "Payment amount verification failed."))
continue
except (KeyError, ValueError, IndexError):
failed_shops.append((shop, "Payment processing error."))
continue
invoice.paypal_order_id = paypal_order_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
):
invoice.paypal_capture_id = order["purchase_units"][0]["payments"]["captures"][0]["id"]
# Handle vaulting
try:
if "payment_source" in order and "paypal" in order["payment_source"]:
paypal_source = order["payment_source"]["paypal"]
if "attributes" in paypal_source and "vault" in paypal_source["attributes"]:
vault_info = paypal_source["attributes"]["vault"]
if vault_info.get("status") == "VAULTED" and vault_info.get("id"):
from ..models.paypal_user_shop import PayPalUserShop
paypal_user_shop = shop.paypal_user_shop(request.user)
if paypal_user_shop is None:
paypal_user_shop = PayPalUserShop(user=request.user, shop=shop)
request.dbsession.add(paypal_user_shop)
paypal_user_shop.active_payment_token = vault_info["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)
except Exception:
pass
for line_item in invoice.line_items:
line_item.product.unlock_for_user(request.user)
request.dbsession.add(line_item.product)
request.dbsession.add(invoice)
successful_invoices.append(invoice)
except Exception as e:
failed_shops.append((shop, f"Unexpected error: {str(e)}"))
continue
if successful_invoices:
for invoice in successful_invoices:
for line_item in invoice.line_items:
cart.remove_product(line_item.product, line_item.quantity)
cart.update_inventory(request.shop_location)
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,
)
if successful_invoices and not failed_shops:
request.session.flash(("Success! You have completed the purchase.", "success"))
elif successful_invoices and failed_shops:
request.session.flash(("Partial success. Some payments failed.", "warning"))
for shop, error_msg in failed_shops:
request.session.flash((f"{shop.name}: {error_msg}", "error"))
else:
request.session.flash(("All payments failed.", "error"))
for shop, error_msg in failed_shops:
request.session.flash((f"{shop.name}: {error_msg}", "error"))
save_cart(request)
if successful_invoices:
return HTTPFound(get_smart_purchase_redirect_url(successful_invoices))
return HTTPFound("/cart")
except Exception as e:
request.tm.abort()
request.session.flash((f"Payment processing failed: {str(e)}", "error"))
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."""
if not request.paypal_enabled:
return {"error": "PayPal payments are disabled"}
cart = get_cart_from_matchdict(request)
if cart is None:
return {"error": "Cart not found"}
if cart.is_empty:
return {"error": "Cart is empty"}
try:
import requests
order_ids = []
invoice_map = {}
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)
for coupon in cart.coupons:
invoice.new_coupon_redemption(coupon)
invoice_map[shop_id] = invoice
for shop_id, invoice in invoice_map.items():
shop = invoice.shop
if not shop.paypal or not shop.is_paypal_ready:
return {"error": f"PayPal is not configured for shop: {shop.name}"}
shop_total_dollars = invoice.total
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"
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:
return {"error": f"PayPal unavailable for {shop.name}."}
access_token = auth_response.json()["access_token"]
currency = getattr(shop, 'currency', None) or 'USD'
save_paypal = request.params.get("save_paypal", "false") == "true"
order_json = {
"intent": "CAPTURE",
"purchase_units": [{
"amount": {
"currency_code": currency,
"value": f"{shop_total_dollars:.2f}"
},
"description": f"Purchase from {shop.name}"
}]
}
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"
}
}
}
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]:
return {"error": f"Unable to create PayPal order for {shop.name}."}
order_data = order_response.json()
order_ids.append(order_data["id"])
return {"order_ids": order_ids}
except Exception as e:
return {"error": str(e)}

View file

@ -1,548 +0,0 @@
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound
from pyramid.response import Response
from ..models.invoice import Invoice
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}")
# Store PayPal payment info on invoice
invoice.paypal_order_id = paypal_order_id
# Extract capture ID from order response
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
):
invoice.paypal_capture_id = order["purchase_units"][0]["payments"]["captures"][0]["id"]
# 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()}] PayPal invoice created - 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)}