Remove PayPalPayment table, use Invoice columns instead
- Add paypal_order_id and paypal_capture_id columns to Invoice model - Update migration to add columns to mps_invoice instead of creating separate table - Remove PayPalPayment model (simpler architecture matching Stripe) - Update paypal.py to store PayPal info directly on Invoice - Update paypal_webhooks.py to query Invoice by paypal_order_id - Update Invoice.payment_method property to detect PayPal payments
This commit is contained in:
parent
b5a32c797e
commit
261ad97c7b
8 changed files with 66 additions and 180 deletions
|
|
@ -5,23 +5,24 @@ Adds PayPal as a payment processor alongside Stripe and crypto (XMR/DOGE).
|
|||
## Files Added
|
||||
|
||||
### Models
|
||||
- `make_post_sell/models/paypal_user_shop.py` - Tracks PayPal payer IDs per user/shop
|
||||
- `make_post_sell/models/paypal_payment.py` - Tracks PayPal transactions per invoice
|
||||
- `make_post_sell/models/paypal_user_shop.py` - Tracks PayPal payer IDs per user/shop (for saved payment methods)
|
||||
|
||||
### Views
|
||||
- `make_post_sell/views/paypal.py` - Order creation and checkout capture
|
||||
- `make_post_sell/views/paypal_webhooks.py` - Webhook handler for payment events
|
||||
|
||||
### Migration
|
||||
- `make_post_sell/scripts/alembic/versions/418933067d81_add_paypal_credentials_to_shop.py`
|
||||
- `make_post_sell/scripts/alembic/versions/418933067d81_add_paypal_support.py`
|
||||
- Adds `paypal_client_id`, `paypal_secret`, `paypal_enabled` to mps_shop
|
||||
- New tables (mps_paypal_user_shop, mps_paypal_payment) are auto-created
|
||||
- Adds `paypal_order_id`, `paypal_capture_id` to mps_invoice
|
||||
- New table (mps_paypal_user_shop) is auto-created
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `make_post_sell/models/__init__.py` - Import new models
|
||||
- `make_post_sell/models/meta.py` - Register models in CLASS_TO_TABLE
|
||||
- `make_post_sell/models/__init__.py` - Import PayPalUserShop
|
||||
- `make_post_sell/models/meta.py` - Register PayPalUserShop in CLASS_TO_TABLE
|
||||
- `make_post_sell/models/shop.py` - PayPal columns, `is_paypal_ready`, `paypal_user_shop()`
|
||||
- `make_post_sell/models/invoice.py` - PayPal columns, updated `payment_method` property
|
||||
- `make_post_sell/routes.py` - PayPal routes
|
||||
- `make_post_sell/request_methods.py` - `request.paypal_enabled`
|
||||
- `make_post_sell/views/cart.py` - Pass PayPal context to checkout template
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ from .crypto_processor import *
|
|||
from .user_crypto_refund_address import *
|
||||
|
||||
from .stripe_user_shop import *
|
||||
|
||||
from .paypal_payment import *
|
||||
from .paypal_user_shop import *
|
||||
|
||||
from .shop_search_request import *
|
||||
|
|
|
|||
|
|
@ -103,6 +103,10 @@ class Invoice(RBase, Base):
|
|||
# denormalized address of user for shipping products.
|
||||
delivery_address = Column(UnicodeText, nullable=True)
|
||||
|
||||
# PayPal payment tracking (nullable - only set for PayPal payments)
|
||||
paypal_order_id = Column(Unicode(64), nullable=True)
|
||||
paypal_capture_id = Column(Unicode(64), nullable=True)
|
||||
|
||||
# one to one.
|
||||
user = relationship(argument="User", uselist=False, lazy="joined")
|
||||
|
||||
|
|
@ -260,16 +264,16 @@ class Invoice(RBase, Base):
|
|||
|
||||
@property
|
||||
def payment_status(self):
|
||||
"""Get payment status from crypto_payment or assume paid for Stripe."""
|
||||
"""Get payment status from crypto_payment or assume paid for Stripe/PayPal."""
|
||||
if hasattr(self, "crypto_payment") and self.crypto_payment:
|
||||
return self.crypto_payment.status
|
||||
else:
|
||||
# If invoice exists without crypto_payment, it's a successful Stripe payment
|
||||
# If invoice exists without crypto_payment, it's a successful Stripe/PayPal payment
|
||||
return "paid"
|
||||
|
||||
@property
|
||||
def payment_method(self):
|
||||
"""Get payment method from crypto_payment or return 'stripe' for card payments."""
|
||||
"""Get payment method: crypto, paypal, or stripe."""
|
||||
try:
|
||||
if hasattr(self, "crypto_payment") and self.crypto_payment:
|
||||
# crypto_payment is a collection, get the first one
|
||||
|
|
@ -281,8 +285,10 @@ class Invoice(RBase, Base):
|
|||
elif hasattr(self.crypto_payment, "coin_type"):
|
||||
return self.crypto_payment.coin_type.lower()
|
||||
except Exception:
|
||||
# Fall back to stripe if there's any issue accessing crypto_payment
|
||||
pass
|
||||
# Check for PayPal payment
|
||||
if self.paypal_order_id:
|
||||
return "paypal"
|
||||
return "stripe"
|
||||
|
||||
@property
|
||||
|
|
@ -301,6 +307,13 @@ def get_invoice_by_id(dbsession, invoice_id):
|
|||
return get_object_by_id(dbsession, invoice_id, Invoice)
|
||||
|
||||
|
||||
def get_invoice_by_paypal_order_id(dbsession, paypal_order_id):
|
||||
"""Try to get Invoice object by PayPal order ID or return None."""
|
||||
return dbsession.query(Invoice).filter(
|
||||
Invoice.paypal_order_id == paypal_order_id
|
||||
).first()
|
||||
|
||||
|
||||
def delete_invoice_by_id(dbsession, invoice_id):
|
||||
"""
|
||||
Safely delete an invoice and its line items, but only if it's from a terminated/unsuccessful crypto payment.
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ CLASS_TO_TABLE = {
|
|||
"ShopSearchRequest": "mps_shop_search_request",
|
||||
"StripeUserShop": "mps_stripe_user_shop",
|
||||
"PayPalUserShop": "mps_paypal_user_shop",
|
||||
"PayPalPayment": "mps_paypal_payment",
|
||||
"Market": "mps_market",
|
||||
"Comment": "mps_comment",
|
||||
"CryptoPayment": "mps_crypto_payment",
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
import uuid
|
||||
|
||||
from sqlalchemy import Column, BigInteger, Unicode
|
||||
|
||||
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
|
||||
|
||||
from sqlalchemy.orm import relationship, backref
|
||||
|
||||
|
||||
class PayPalPayment(RBase, Base):
|
||||
"""
|
||||
Tracks PayPal payment transactions for invoices.
|
||||
Stores PayPal order/payment IDs and status for each transaction.
|
||||
"""
|
||||
|
||||
id = Column(UUIDType, primary_key=True, index=True)
|
||||
invoice_id = Column(UUIDType, foreign_key("Invoice", "id"), nullable=False)
|
||||
|
||||
# PayPal order ID (e.g., "5O190127TN364715T")
|
||||
paypal_order_id = Column(Unicode(64), nullable=False)
|
||||
|
||||
# PayPal payer ID
|
||||
paypal_payer_id = Column(Unicode(64), nullable=True)
|
||||
|
||||
# PayPal capture ID (after order is captured)
|
||||
paypal_capture_id = Column(Unicode(64), nullable=True)
|
||||
|
||||
# Payment status: CREATED, APPROVED, VOIDED, COMPLETED, PAYER_ACTION_REQUIRED
|
||||
status = Column(Unicode(32), nullable=False, default="CREATED")
|
||||
|
||||
# Amount in cents
|
||||
amount_in_cents = Column(BigInteger, nullable=False)
|
||||
|
||||
created_timestamp = Column(BigInteger, nullable=False)
|
||||
updated_timestamp = Column(BigInteger, nullable=False)
|
||||
|
||||
invoice = relationship(
|
||||
argument="Invoice",
|
||||
backref=backref("paypal_payment", uselist=False, cascade="all, delete-orphan"),
|
||||
)
|
||||
|
||||
def __init__(self, invoice=None):
|
||||
self.id = uuid.uuid1()
|
||||
self.invoice = invoice
|
||||
self.created_timestamp = now_timestamp()
|
||||
self.updated_timestamp = now_timestamp()
|
||||
|
||||
@property
|
||||
def is_completed(self):
|
||||
"""Check if payment is completed."""
|
||||
return self.status == "COMPLETED"
|
||||
|
||||
@property
|
||||
def is_pending(self):
|
||||
"""Check if payment is pending approval/capture."""
|
||||
return self.status in ["CREATED", "APPROVED", "PAYER_ACTION_REQUIRED"]
|
||||
|
||||
@property
|
||||
def is_failed(self):
|
||||
"""Check if payment failed or was voided."""
|
||||
return self.status == "VOIDED"
|
||||
|
||||
def update_status(self, new_status):
|
||||
"""Update payment status and timestamp."""
|
||||
self.status = new_status
|
||||
self.updated_timestamp = now_timestamp()
|
||||
|
||||
|
||||
def get_paypal_payment_by_order_id(dbsession, paypal_order_id):
|
||||
"""Get PayPalPayment by PayPal order ID."""
|
||||
return (
|
||||
dbsession.query(PayPalPayment)
|
||||
.filter(PayPalPayment.paypal_order_id == paypal_order_id)
|
||||
.one_or_none()
|
||||
)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
"""add paypal credentials to shop
|
||||
"""add paypal support
|
||||
|
||||
Revision ID: 418933067d81
|
||||
Revises: a7c3e8f1d2b4
|
||||
|
|
@ -15,8 +15,6 @@ down_revision = 'a7c3e8f1d2b4'
|
|||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from make_post_sell.models.meta import UUIDType
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Add PayPal credentials columns to mps_shop table
|
||||
|
|
@ -32,9 +30,21 @@ def upgrade():
|
|||
"mps_shop",
|
||||
sa.Column("paypal_enabled", sa.Boolean(), nullable=False, server_default="1"),
|
||||
)
|
||||
# Add PayPal payment tracking columns to mps_invoice table
|
||||
op.add_column(
|
||||
"mps_invoice",
|
||||
sa.Column("paypal_order_id", sa.Unicode(64), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"mps_invoice",
|
||||
sa.Column("paypal_capture_id", sa.Unicode(64), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Remove PayPal columns from mps_invoice table
|
||||
op.drop_column("mps_invoice", "paypal_capture_id")
|
||||
op.drop_column("mps_invoice", "paypal_order_id")
|
||||
# Remove PayPal columns from mps_shop table
|
||||
op.drop_column("mps_shop", "paypal_enabled")
|
||||
op.drop_column("mps_shop", "paypal_secret")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ 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 (
|
||||
|
|
@ -225,16 +224,10 @@ def paypal_complete_checkout(request):
|
|||
# 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"]
|
||||
# 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
|
||||
|
|
@ -242,9 +235,7 @@ def paypal_complete_checkout(request):
|
|||
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)
|
||||
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
|
||||
|
|
@ -296,7 +287,7 @@ def paypal_complete_checkout(request):
|
|||
|
||||
# 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}")
|
||||
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)}")
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
from pyramid.view import view_config
|
||||
from pyramid.response import Response
|
||||
|
||||
from ..models.paypal_payment import get_paypal_payment_by_order_id
|
||||
from ..models.invoice import get_invoice_by_paypal_order_id
|
||||
|
||||
import json
|
||||
import paypalrestsdk
|
||||
|
||||
|
||||
@view_config(route_name="paypal_webhook", request_method="POST")
|
||||
|
|
@ -27,8 +26,17 @@ def paypal_webhook(request):
|
|||
# Get webhook ID from config for verification
|
||||
webhook_id = request.app.get("paypal.webhook_id")
|
||||
|
||||
event_type = webhook_event.get("event_type")
|
||||
resource = webhook_event.get("resource", {})
|
||||
|
||||
# Extract order ID based on event type
|
||||
order_id = None
|
||||
if "supplementary_data" in resource:
|
||||
related_ids = resource.get("supplementary_data", {}).get("related_ids", {})
|
||||
order_id = related_ids.get("order_id")
|
||||
|
||||
# Verify webhook signature if webhook_id is configured
|
||||
if webhook_id:
|
||||
if webhook_id and order_id:
|
||||
try:
|
||||
# Get headers for verification
|
||||
transmission_id = request.headers.get("PAYPAL-TRANSMISSION-ID")
|
||||
|
|
@ -45,25 +53,9 @@ def paypal_webhook(request):
|
|||
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
|
||||
invoice = get_invoice_by_paypal_order_id(request.dbsession, order_id)
|
||||
if not invoice or not invoice.shop:
|
||||
print(f"PayPal webhook rejected: Cannot determine shop for order {order_id}")
|
||||
return Response(
|
||||
json.dumps({"status": "error", "message": "Cannot verify webhook - shop unknown"}),
|
||||
|
|
@ -71,11 +63,10 @@ def paypal_webhook(request):
|
|||
status=400
|
||||
)
|
||||
|
||||
shop = invoice.shop
|
||||
|
||||
# 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
|
||||
|
|
@ -135,11 +126,9 @@ def paypal_webhook(request):
|
|||
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"}),
|
||||
|
|
@ -147,68 +136,29 @@ def paypal_webhook(request):
|
|||
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
|
||||
# Process the webhook event
|
||||
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)
|
||||
invoice = get_invoice_by_paypal_order_id(request.dbsession, order_id)
|
||||
if invoice and "id" in resource:
|
||||
invoice.paypal_capture_id = resource["id"]
|
||||
request.dbsession.add(invoice)
|
||||
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")
|
||||
|
||||
# Payment was denied - log it
|
||||
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()
|
||||
print(f"PayPal payment denied for order {order_id}")
|
||||
|
||||
elif event_type == "CUSTOMER.DISPUTE.CREATED":
|
||||
# A dispute was created
|
||||
# A dispute was created - log for manual review
|
||||
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")
|
||||
print(f"[{datetime.now().isoformat()}] CRITICAL: PayPal dispute created - Dispute ID: {dispute_id}, Amount: ${dispute_amount}, Reason: {dispute_reason}")
|
||||
|
||||
# Return success response
|
||||
return Response(
|
||||
|
|
@ -218,7 +168,6 @@ def paypal_webhook(request):
|
|||
)
|
||||
|
||||
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)}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue