From 261ad97c7b28261232ed209cf74e2730d98eb667 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Mon, 22 Dec 2025 12:51:07 -0500 Subject: [PATCH] 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 --- docs/CHANGES_PAYPAL.md | 13 +-- make_post_sell/models/__init__.py | 2 - make_post_sell/models/invoice.py | 21 +++- make_post_sell/models/meta.py | 1 - make_post_sell/models/paypal_payment.py | 75 -------------- ...33067d81_add_paypal_credentials_to_shop.py | 16 ++- make_post_sell/views/paypal.py | 19 +--- make_post_sell/views/paypal_webhooks.py | 99 +++++-------------- 8 files changed, 66 insertions(+), 180 deletions(-) delete mode 100644 make_post_sell/models/paypal_payment.py diff --git a/docs/CHANGES_PAYPAL.md b/docs/CHANGES_PAYPAL.md index 07d5058..37ecf3e 100644 --- a/docs/CHANGES_PAYPAL.md +++ b/docs/CHANGES_PAYPAL.md @@ -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 diff --git a/make_post_sell/models/__init__.py b/make_post_sell/models/__init__.py index 3e30291..837599a 100644 --- a/make_post_sell/models/__init__.py +++ b/make_post_sell/models/__init__.py @@ -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 * diff --git a/make_post_sell/models/invoice.py b/make_post_sell/models/invoice.py index 2e96fa2..6ca208e 100644 --- a/make_post_sell/models/invoice.py +++ b/make_post_sell/models/invoice.py @@ -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. diff --git a/make_post_sell/models/meta.py b/make_post_sell/models/meta.py index 89b3e1f..37113f2 100644 --- a/make_post_sell/models/meta.py +++ b/make_post_sell/models/meta.py @@ -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", diff --git a/make_post_sell/models/paypal_payment.py b/make_post_sell/models/paypal_payment.py deleted file mode 100644 index f6608c3..0000000 --- a/make_post_sell/models/paypal_payment.py +++ /dev/null @@ -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() - ) diff --git a/make_post_sell/scripts/alembic/versions/418933067d81_add_paypal_credentials_to_shop.py b/make_post_sell/scripts/alembic/versions/418933067d81_add_paypal_credentials_to_shop.py index 3bd302d..cd93ccc 100644 --- a/make_post_sell/scripts/alembic/versions/418933067d81_add_paypal_credentials_to_shop.py +++ b/make_post_sell/scripts/alembic/versions/418933067d81_add_paypal_credentials_to_shop.py @@ -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") diff --git a/make_post_sell/views/paypal.py b/make_post_sell/views/paypal.py index eb5f0ef..6fdf4d0 100644 --- a/make_post_sell/views/paypal.py +++ b/make_post_sell/views/paypal.py @@ -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)}") diff --git a/make_post_sell/views/paypal_webhooks.py b/make_post_sell/views/paypal_webhooks.py index 717005f..92087f3 100644 --- a/make_post_sell/views/paypal_webhooks.py +++ b/make_post_sell/views/paypal_webhooks.py @@ -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)}),