installed plugin Easy Digital Downloads
version 3.1.0.3
This commit is contained in:
@ -0,0 +1,163 @@
|
||||
<?php
|
||||
/**
|
||||
* Webhook Handler
|
||||
*
|
||||
* @package easy-digital-downloads
|
||||
* @subpackage Gateways\PayPal\Webhooks
|
||||
* @copyright Copyright (c) 2021, Sandhills Development, LLC
|
||||
* @license GPL2+
|
||||
* @since 2.11
|
||||
*/
|
||||
|
||||
namespace EDD\Gateways\PayPal\Webhooks;
|
||||
|
||||
use EDD\Gateways\PayPal;
|
||||
|
||||
class Webhook_Handler {
|
||||
/**
|
||||
* Endpoint namespace.
|
||||
*
|
||||
* @since 2.11
|
||||
*/
|
||||
const REST_NAMESPACE = 'edd/webhooks/v1';
|
||||
|
||||
/**
|
||||
* Endpoint route.
|
||||
*
|
||||
* @since 2.11
|
||||
*/
|
||||
const REST_ROUTE = 'paypal';
|
||||
|
||||
/**
|
||||
* Webhook payload
|
||||
*
|
||||
* @var object
|
||||
* @since 2.11
|
||||
*/
|
||||
private $event;
|
||||
|
||||
/**
|
||||
* Registers REST API routes.
|
||||
*
|
||||
* @since 2.11
|
||||
* @return void
|
||||
*/
|
||||
public function register_routes() {
|
||||
register_rest_route( self::REST_NAMESPACE, self::REST_ROUTE, array(
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'handle_request' ),
|
||||
'permission_callback' => array( $this, 'validate_request' )
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the current request.
|
||||
*
|
||||
* @param \WP_REST_Request $request
|
||||
*
|
||||
* @since 2.11
|
||||
* @return \WP_REST_Response
|
||||
*/
|
||||
public function handle_request( \WP_REST_Request $request ) {
|
||||
edd_debug_log( sprintf(
|
||||
'PayPal Commerce webhook endpoint loaded. Mode: %s; Event: %s',
|
||||
( edd_is_test_mode() ? 'sandbox' : 'live' ),
|
||||
$request->get_param( 'event_type' )
|
||||
) );
|
||||
|
||||
edd_debug_log( sprintf( 'Payload: %s', json_encode( $this->event ) ) ); // @todo remove
|
||||
|
||||
try {
|
||||
// We need to match this event to one of our handlers.
|
||||
$events = get_webhook_events();
|
||||
if ( ! array_key_exists( $request->get_param( 'event_type' ), $events ) ) {
|
||||
throw new \Exception( sprintf( 'Event not registered. Event: %s', esc_html( $request->get_param( 'event_type' ) ) ), 200 );
|
||||
}
|
||||
|
||||
$class_name = $events[ $request->get_param( 'event_type' ) ];
|
||||
|
||||
if ( ! class_exists( $class_name ) ) {
|
||||
throw new \Exception( sprintf( 'Class %s doesn\'t exist for event type.', $class_name ), 500 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the handler for this event.
|
||||
*
|
||||
* @var PayPal\Webhooks\Events\Webhook_Event $handler
|
||||
*/
|
||||
$handler = new $class_name( $request );
|
||||
|
||||
if ( ! method_exists( $handler, 'handle' ) ) {
|
||||
throw new \Exception( sprintf( 'handle() method doesn\'t exist in class %s.', $class_name ), 500 );
|
||||
}
|
||||
|
||||
edd_debug_log( sprintf( 'PayPal Commerce Webhook - Passing to handler %s', esc_html( $class_name ) ) );
|
||||
|
||||
$handler->handle();
|
||||
|
||||
$action_key = sanitize_key( strtolower( str_replace( '.', '_', $request->get_param( 'event_type' ) ) ) );
|
||||
/**
|
||||
* Triggers once the handler has run successfully.
|
||||
* $action_key is a formatted version of the event type:
|
||||
* - All lowercase
|
||||
* - Full stops `.` replaced with underscores `_`
|
||||
*
|
||||
* Note: This action hook exists so you can execute custom code *after* a handler has run.
|
||||
* If you're registering a custom event, please build a custom handler by extending
|
||||
* the `Webhook_Event` class and not via this hook.
|
||||
*
|
||||
* @param \WP_REST_Request $event
|
||||
*
|
||||
* @since 2.11
|
||||
*/
|
||||
do_action( 'edd_paypal_webhook_event_' . $action_key, $request );
|
||||
|
||||
return new \WP_REST_Response( 'Success', 200 );
|
||||
} catch ( PayPal\Exceptions\Authentication_Exception $e ) {
|
||||
// Failure with PayPal credentials.
|
||||
edd_debug_log( sprintf( 'PayPal Commerce Webhook - Exiting due to authentication exception. Message: %s', $e->getMessage() ), true );
|
||||
|
||||
return new \WP_REST_Response( $e->getMessage(), 403 );
|
||||
} catch ( PayPal\Exceptions\API_Exception $e ) {
|
||||
// Failure with a PayPal API request.
|
||||
edd_debug_log( sprintf( 'PayPal Commerce Webhook - Failure due to an API exception. Message: %s', $e->getMessage() ) );
|
||||
|
||||
return new \WP_REST_Response( $e->getMessage(), 500 );
|
||||
} catch ( \Exception $e ) {
|
||||
edd_debug_log( sprintf( 'PayPal Commerce - Exiting webhook due to an exception. Message: %s', $e->getMessage() ), true );
|
||||
|
||||
$response_code = $e->getCode() > 0 ? $e->getCode() : 500;
|
||||
|
||||
return new \WP_REST_Response( $e->getMessage(), $response_code );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the webhook
|
||||
*
|
||||
* @since 2.11
|
||||
* @return bool|\WP_Error
|
||||
*/
|
||||
public function validate_request() {
|
||||
if ( ! PayPal\has_rest_api_connection() ) {
|
||||
return new \WP_Error( 'missing_api_credentials', 'API credentials not set.' );
|
||||
}
|
||||
|
||||
$this->event = json_decode( file_get_contents( 'php://input' ) );
|
||||
|
||||
try {
|
||||
Webhook_Validator::validate_from_request( $this->event );
|
||||
|
||||
edd_debug_log( 'PayPal Commerce webhook successfully validated.' );
|
||||
|
||||
return true;
|
||||
} catch ( \Exception $e ) {
|
||||
return new \WP_Error( 'validation_failure', $e->getMessage() );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
add_action( 'rest_api_init', function () {
|
||||
$handler = new Webhook_Handler();
|
||||
$handler->register_routes();
|
||||
} );
|
@ -0,0 +1,168 @@
|
||||
<?php
|
||||
/**
|
||||
* Webhook Validator
|
||||
*
|
||||
* @link https://developer.paypal.com/docs/api/webhooks/v1/#verify-webhook-signature_post
|
||||
*
|
||||
* @package easy-digital-downloads
|
||||
* @subpackage Gateways\PayPal\Webhooks
|
||||
* @copyright Copyright (c) 2021, Sandhills Development, LLC
|
||||
* @license GPL2+
|
||||
* @since 2.11
|
||||
*/
|
||||
|
||||
namespace EDD\Gateways\PayPal\Webhooks;
|
||||
|
||||
use EDD\Gateways\PayPal\API;
|
||||
use EDD\Gateways\PayPal\Exceptions\API_Exception;
|
||||
|
||||
class Webhook_Validator {
|
||||
|
||||
/**
|
||||
* Headers from the webhook
|
||||
*
|
||||
* @var array
|
||||
* @since 2.11
|
||||
*/
|
||||
private $headers;
|
||||
|
||||
/**
|
||||
* Webhook event
|
||||
*
|
||||
* @var object
|
||||
* @since 2.11
|
||||
*/
|
||||
private $event;
|
||||
|
||||
/**
|
||||
* Maps the incoming header key to the outgoing API request key.
|
||||
*
|
||||
* @var string[]
|
||||
* @since 2.11
|
||||
*/
|
||||
private $header_map = array(
|
||||
'PAYPAL-AUTH-ALGO' => 'auth_algo',
|
||||
'PAYPAL-CERT-URL' => 'cert_url',
|
||||
'PAYPAL-TRANSMISSION-ID' => 'transmission_id',
|
||||
'PAYPAL-TRANSMISSION-SIG' => 'transmission_sig',
|
||||
'PAYPAL-TRANSMISSION-TIME' => 'transmission_time'
|
||||
);
|
||||
|
||||
/**
|
||||
* Webhook_Validator constructor.
|
||||
*
|
||||
* @param array $headers
|
||||
* @param object $event
|
||||
*
|
||||
* @since 2.11
|
||||
*/
|
||||
public function __construct( $headers, $event ) {
|
||||
$this->headers = array_change_key_case( $headers, CASE_UPPER );
|
||||
$this->event = $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the signature.
|
||||
*
|
||||
* @since 2.11
|
||||
* @return true
|
||||
* @throws API_Exception
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function verify_signature() {
|
||||
$api = new API();
|
||||
|
||||
$response = $api->make_request( 'v1/notifications/verify-webhook-signature', $this->get_body() );
|
||||
|
||||
if ( 200 !== $api->last_response_code ) {
|
||||
throw new API_Exception( sprintf(
|
||||
'Invalid response code: %d. Response: %s',
|
||||
$api->last_response_code,
|
||||
json_encode( $response )
|
||||
) );
|
||||
}
|
||||
|
||||
if ( empty( $response->verification_status ) || 'SUCCESS' !== strtoupper( $response->verification_status ) ) {
|
||||
throw new API_Exception( sprintf(
|
||||
'Verification failure. Response: %s',
|
||||
json_encode( $response )
|
||||
) );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that we have all the required headers.
|
||||
*
|
||||
* @since 2.11
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
private function validate_headers() {
|
||||
foreach ( array_keys( $this->header_map ) as $required_key ) {
|
||||
if ( ! array_key_exists( $required_key, $this->headers ) ) {
|
||||
throw new \InvalidArgumentException( sprintf(
|
||||
'Missing PayPal header %s',
|
||||
$required_key
|
||||
) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the webhook ID for the current mode.
|
||||
*
|
||||
* @since 2.11
|
||||
* @return string
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function get_webhook_id() {
|
||||
$id = get_webhook_id();
|
||||
|
||||
if ( empty( $id ) ) {
|
||||
throw new \Exception( 'No webhook created in current mode.' );
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds arguments for the body of the API request.
|
||||
*
|
||||
* @return array
|
||||
* @throws \InvalidArgumentException
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function get_body() {
|
||||
$this->validate_headers();
|
||||
|
||||
$body = array(
|
||||
'webhook_id' => $this->get_webhook_id(),
|
||||
'webhook_event' => $this->event
|
||||
);
|
||||
|
||||
// Add arguments from the headers.
|
||||
foreach ( $this->header_map as $header_key => $body_key ) {
|
||||
$body[ $body_key ] = $this->headers[ $header_key ];
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the webhook from the current request.
|
||||
*
|
||||
* @param object $event Webhook event.
|
||||
*
|
||||
* @since 2.11
|
||||
* @return true
|
||||
* @throws API_Exception
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public static function validate_from_request( $event ) {
|
||||
$validator = new Webhook_Validator( getallheaders(), $event );
|
||||
|
||||
return $validator->verify_signature();
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,291 @@
|
||||
<?php
|
||||
/**
|
||||
* Webhook Event Handler
|
||||
*
|
||||
* @package easy-digital-downloads
|
||||
* @subpackage Gateways\PayPal\Webhooks\Events
|
||||
* @copyright Copyright (c) 2021, Sandhills Development, LLC
|
||||
* @license GPL2+
|
||||
* @since 2.11
|
||||
*/
|
||||
|
||||
namespace EDD\Gateways\PayPal\Webhooks\Events;
|
||||
|
||||
use EDD\Gateways\PayPal\API;
|
||||
use EDD\Gateways\PayPal\Exceptions\API_Exception;
|
||||
use EDD\Gateways\PayPal\Exceptions\Authentication_Exception;
|
||||
use EDD\Orders\Order;
|
||||
|
||||
abstract class Webhook_Event {
|
||||
|
||||
/**
|
||||
* API request
|
||||
*
|
||||
* @var \WP_REST_Request
|
||||
* @since 2.11
|
||||
*/
|
||||
protected $request;
|
||||
|
||||
/**
|
||||
* Data from the request.
|
||||
*
|
||||
* @var object
|
||||
* @since 2.11
|
||||
*/
|
||||
protected $event;
|
||||
|
||||
/**
|
||||
* Webhook_Event constructor.
|
||||
*
|
||||
* @param \WP_REST_Request $request
|
||||
*
|
||||
* @since 2.11
|
||||
*/
|
||||
public function __construct( $request ) {
|
||||
$this->request = $request;
|
||||
|
||||
// `get_params()` returns an array, but we want an object.
|
||||
$this->event = json_decode( json_encode( $this->request->get_params() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the webhook event.
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function handle() {
|
||||
$this->process_event();
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the event.
|
||||
*
|
||||
* @since 2.11
|
||||
* @return void
|
||||
*/
|
||||
abstract protected function process_event();
|
||||
|
||||
/**
|
||||
* Retrieves an Order record from a capture event.
|
||||
*
|
||||
* @since 3.0
|
||||
*
|
||||
* @return Order
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function get_order_from_capture() {
|
||||
if ( 'capture' !== $this->request->get_param( 'resource_type' ) ) {
|
||||
throw new \Exception( sprintf( 'get_payment_from_capture() - Invalid resource type: %s', $this->request->get_param( 'resource_type' ) ) );
|
||||
}
|
||||
|
||||
if ( empty( $this->event->resource ) ) {
|
||||
throw new \Exception( sprintf( 'get_payment_from_capture() - Missing event resource.' ) );
|
||||
}
|
||||
|
||||
return $this->get_order_from_capture_object( $this->event->resource );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an Order record from a capture object.
|
||||
*
|
||||
* @param object $resource
|
||||
*
|
||||
* @since 3.0
|
||||
*
|
||||
* @return Order
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function get_order_from_capture_object( $resource ) {
|
||||
$order = false;
|
||||
|
||||
if ( ! empty( $resource->custom_id ) && is_numeric( $resource->custom_id ) ) {
|
||||
$order = edd_get_order( $resource->custom_id );
|
||||
}
|
||||
|
||||
if ( empty( $order ) && ! empty( $resource->id ) ) {
|
||||
$order_id = edd_get_order_id_from_transaction_id( $resource->id );
|
||||
$order = $order_id ? edd_get_order( $order_id ) : false;
|
||||
}
|
||||
|
||||
if ( ! $order instanceof Order ) {
|
||||
throw new \Exception( 'get_order_from_capture_object() - Failed to locate order.', 200 );
|
||||
}
|
||||
|
||||
/*
|
||||
* Verify the transaction ID. This covers us in case we fetched the order via `custom_id`, but
|
||||
* it wasn't actually an EDD-initiated payment.
|
||||
*/
|
||||
$order_transaction_id = $order->get_transaction_id();
|
||||
if ( $order_transaction_id !== $resource->id ) {
|
||||
throw new \Exception( sprintf(
|
||||
'get_order_from_capture_object() - Transaction ID mismatch. Expected: %s; Actual: %s',
|
||||
$order_transaction_id,
|
||||
$resource->id
|
||||
), 200 );
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an Order record from a refund event.
|
||||
*
|
||||
* @since 3.0
|
||||
*
|
||||
* @return Order
|
||||
* @throws API_Exception
|
||||
* @throws Authentication_Exception
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function get_order_from_refund() {
|
||||
edd_debug_log( sprintf(
|
||||
'PayPal Commerce Webhook - get_payment_from_capture_object() - Resource type: %s; Resource ID: %s',
|
||||
$this->request->get_param( 'resource_type' ),
|
||||
$this->event->resource->id
|
||||
) );
|
||||
|
||||
if ( empty( $this->event->resource->links ) || ! is_array( $this->event->resource->links ) ) {
|
||||
throw new \Exception( 'Missing resources.', 200 );
|
||||
}
|
||||
|
||||
$order_link = current( array_filter( $this->event->resource->links, function ( $link ) {
|
||||
return ! empty( $link->rel ) && 'up' === strtolower( $link->rel );
|
||||
} ) );
|
||||
|
||||
if ( empty( $order_link->href ) ) {
|
||||
throw new \Exception( 'Missing order link.', 200 );
|
||||
}
|
||||
|
||||
// Based on the payment link, determine which mode we should act in.
|
||||
if ( false === strpos( $order_link->href, 'sandbox.paypal.com' ) ) {
|
||||
$mode = API::MODE_LIVE;
|
||||
} else {
|
||||
$mode = API::MODE_SANDBOX;
|
||||
}
|
||||
|
||||
// Look up the full order record in PayPal.
|
||||
$api = new API( $mode );
|
||||
$response = $api->make_request( $order_link->href, array(), array(), $order_link->method );
|
||||
|
||||
if ( 200 !== $api->last_response_code ) {
|
||||
throw new API_Exception( sprintf( 'Invalid response code when retrieving order record: %d', $api->last_response_code ) );
|
||||
}
|
||||
|
||||
if ( empty( $response->id ) ) {
|
||||
throw new API_Exception( 'Missing order ID from API response.' );
|
||||
}
|
||||
|
||||
return $this->get_order_from_capture_object( $response );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an EDD_Payment record from a capture event.
|
||||
*
|
||||
* @since 2.11
|
||||
* @deprecated 3.0 In favour of `get_order_from_capture()`
|
||||
* @see Webhook_Event::get_order_from_capture()
|
||||
*
|
||||
* @return \EDD_Payment
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function get_payment_from_capture() {
|
||||
if ( 'capture' !== $this->request->get_param( 'resource_type' ) ) {
|
||||
throw new \Exception( sprintf( 'get_payment_from_capture() - Invalid resource type: %s', $this->request->get_param( 'resource_type' ) ) );
|
||||
}
|
||||
|
||||
if ( empty( $this->event->resource ) ) {
|
||||
throw new \Exception( sprintf( 'get_payment_from_capture() - Missing event resource.' ) );
|
||||
}
|
||||
|
||||
return $this->get_payment_from_capture_object( $this->event->resource );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an EDD_Payment record from a capture object.
|
||||
*
|
||||
* @param object $resource
|
||||
*
|
||||
* @since 2.11
|
||||
* @deprecated 3.0 In favour of `get_order_from_capture_object()`
|
||||
* @see Webhook_Event::get_order_from_capture_object
|
||||
*
|
||||
* @return \EDD_Payment
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function get_payment_from_capture_object( $resource ) {
|
||||
$payment = false;
|
||||
|
||||
if ( ! empty( $resource->custom_id ) && is_numeric( $resource->custom_id ) ) {
|
||||
$payment = edd_get_payment( $resource->custom_id );
|
||||
}
|
||||
|
||||
if ( empty( $payment ) && ! empty( $resource->id ) ) {
|
||||
$payment_id = edd_get_purchase_id_by_transaction_id( $resource->id );
|
||||
$payment = $payment_id ? edd_get_payment( $payment_id ) : false;
|
||||
}
|
||||
|
||||
if ( ! $payment instanceof \EDD_Payment ) {
|
||||
throw new \Exception( 'get_payment_from_capture_object() - Failed to locate payment.', 200 );
|
||||
}
|
||||
|
||||
/*
|
||||
* Verify the transaction ID. This covers us in case we fetched the payment via `custom_id`, but
|
||||
* it wasn't actually an EDD-initiated payment.
|
||||
*/
|
||||
if ( $payment->transaction_id !== $resource->id ) {
|
||||
throw new \Exception( sprintf( 'get_payment_from_capture_object() - Transaction ID mismatch. Expected: %s; Actual: %s', $payment->transaction_id, $resource->id ), 200 );
|
||||
}
|
||||
|
||||
return $payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an EDD_Payment record from a refund event.
|
||||
*
|
||||
* @since 2.11
|
||||
* @deprecated 3.0 In favour of `get_order_from_refund`
|
||||
* @see Webhook_Event::get_order_from_refund
|
||||
*
|
||||
* @return \EDD_Payment
|
||||
* @throws API_Exception
|
||||
* @throws Authentication_Exception
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function get_payment_from_refund() {
|
||||
edd_debug_log( sprintf( 'PayPal Commerce Webhook - get_payment_from_refund() - Resource type: %s; Resource ID: %s', $this->request->get_param( 'resource_type' ), $this->event->resource->id ) );
|
||||
|
||||
if ( empty( $this->event->resource->links ) || ! is_array( $this->event->resource->links ) ) {
|
||||
throw new \Exception( 'Missing resources.', 200 );
|
||||
}
|
||||
|
||||
$order_link = current( array_filter( $this->event->resource->links, function ( $link ) {
|
||||
return ! empty( $link->rel ) && 'up' === strtolower( $link->rel );
|
||||
} ) );
|
||||
|
||||
if ( empty( $order_link->href ) ) {
|
||||
throw new \Exception( 'Missing order link.', 200 );
|
||||
}
|
||||
|
||||
// Based on the payment link, determine which mode we should act in.
|
||||
if ( false === strpos( $order_link->href, 'sandbox.paypal.com' ) ) {
|
||||
$mode = API::MODE_LIVE;
|
||||
} else {
|
||||
$mode = API::MODE_SANDBOX;
|
||||
}
|
||||
|
||||
// Look up the full order record in PayPal.
|
||||
$api = new API( $mode );
|
||||
$response = $api->make_request( $order_link->href, array(), array(), $order_link->method );
|
||||
|
||||
if ( 200 !== $api->last_response_code ) {
|
||||
throw new API_Exception( sprintf( 'Invalid response code when retrieving order record: %d', $api->last_response_code ) );
|
||||
}
|
||||
|
||||
if ( empty( $response->id ) ) {
|
||||
throw new API_Exception( 'Missing order ID from API response.' );
|
||||
}
|
||||
|
||||
return $this->get_payment_from_capture_object( $response );
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
/**
|
||||
* Webhook Event: PAYMENT.CAPTURE.COMPLETED
|
||||
*
|
||||
* @package easy-digital-downloads
|
||||
* @subpackage Gateways\PayPal\Webhooks\Events
|
||||
* @copyright Copyright (c) 2021, Sandhills Development, LLC
|
||||
* @license GPL2+
|
||||
* @since 2.11
|
||||
*/
|
||||
|
||||
namespace EDD\Gateways\PayPal\Webhooks\Events;
|
||||
|
||||
use EDD\Gateways\PayPal\Exceptions\API_Exception;
|
||||
use EDD\Gateways\PayPal\Exceptions\Authentication_Exception;
|
||||
|
||||
class Payment_Capture_Completed extends Webhook_Event {
|
||||
|
||||
/**
|
||||
* Processes the event.
|
||||
*
|
||||
* @throws API_Exception
|
||||
* @throws Authentication_Exception
|
||||
* @throws \Exception
|
||||
*
|
||||
* @since 2.11
|
||||
*/
|
||||
protected function process_event() {
|
||||
$order = $this->get_order_from_capture();
|
||||
|
||||
// Bail if the payment has already been completed.
|
||||
if ( 'complete' === $order->status ) {
|
||||
edd_debug_log( 'PayPal Commerce - Exiting webhook, as order is already complete.' );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ( empty( $this->event->resource->status ) || 'COMPLETED' !== strtoupper( $this->event->resource->status ) ) {
|
||||
throw new \Exception( 'Capture status is not complete.', 200 );
|
||||
}
|
||||
|
||||
if ( ! isset( $this->event->resource->amount->value ) ) {
|
||||
throw new \Exception( 'Missing amount value.', 200 );
|
||||
}
|
||||
|
||||
if ( ! isset( $this->event->resource->amount->currency_code ) || strtoupper( $this->event->resource->amount->currency_code ) !== strtoupper( $order->currency ) ) {
|
||||
throw new \Exception( sprintf( 'Missing or invalid currency code. Expected: %s; PayPal: %s', $order->currency, esc_html( $this->event->resource->amount->currency_code ) ), 200 );
|
||||
}
|
||||
|
||||
$paypal_amount = (float) $this->event->resource->amount->value;
|
||||
$order_amount = edd_get_payment_amount( $order->id );
|
||||
|
||||
if ( $paypal_amount < $order_amount ) {
|
||||
edd_record_gateway_error(
|
||||
__( 'Webhook Error', 'easy-digital-downloads' ),
|
||||
sprintf(
|
||||
/* Translators: %s is the webhook data */
|
||||
__( 'Invalid payment about in webhook response. Webhook data: %s', 'easy-digital-downloads' ),
|
||||
json_encode( $this->event )
|
||||
)
|
||||
);
|
||||
|
||||
edd_update_order_status( $order->id, 'failed' );
|
||||
edd_add_note( array(
|
||||
'object_type' => 'order',
|
||||
'object_id' => $order->id,
|
||||
'content' => sprintf(
|
||||
__( 'Payment failed due to invalid amount in PayPal webhook. Expected amount: %s; PayPal amount: %s.', 'easy-digital-downloads' ),
|
||||
$order_amount,
|
||||
esc_html( $paypal_amount )
|
||||
)
|
||||
) );
|
||||
|
||||
throw new \Exception( sprintf( 'Webhook amount (%s) doesn\'t match payment amount (%s).', esc_html( $paypal_amount ), esc_html( $order_amount ) ), 200 );
|
||||
}
|
||||
|
||||
edd_update_order_status( $order->id, 'complete' );
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
/**
|
||||
* Webhook Event: PAYMENT.CAPTURE.DENIED
|
||||
*
|
||||
* @package easy-digital-downloads
|
||||
* @subpackage Gateways\PayPal\Webhooks\Events
|
||||
* @copyright Copyright (c) 2021, Sandhills Development, LLC
|
||||
* @license GPL2+
|
||||
* @since 2.11
|
||||
*/
|
||||
|
||||
namespace EDD\Gateways\PayPal\Webhooks\Events;
|
||||
|
||||
class Payment_Capture_Denied extends Webhook_Event {
|
||||
|
||||
/**
|
||||
* Processes the webhook event
|
||||
*
|
||||
* @since 2.11
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function process_event() {
|
||||
$order = $this->get_order_from_capture();
|
||||
|
||||
edd_update_order_status( $order->id, 'failed' );
|
||||
|
||||
edd_add_note( array(
|
||||
'object_type' => 'order',
|
||||
'object_id' => $order->id,
|
||||
'content' => __( 'PayPal transaction denied.', 'easy-digital-downloads' ),
|
||||
) );
|
||||
}
|
||||
}
|
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
/**
|
||||
* Webhook Events:
|
||||
*
|
||||
* - PAYMENT.CAPTURE.REFUNDED - Merchant refunds a sale.
|
||||
* - PAYMENT.CAPTURE.REVERSED - PayPal reverses a sale.
|
||||
*
|
||||
* @package easy-digital-downloads
|
||||
* @subpackage Gateways\PayPal\Webhooks\Events
|
||||
* @copyright Copyright (c) 2021, Sandhills Development, LLC
|
||||
* @license GPL2+
|
||||
* @since 2.11
|
||||
*/
|
||||
|
||||
namespace EDD\Gateways\PayPal\Webhooks\Events;
|
||||
|
||||
use EDD\Gateways\PayPal\Exceptions\API_Exception;
|
||||
use EDD\Gateways\PayPal\Exceptions\Authentication_Exception;
|
||||
|
||||
class Payment_Capture_Refunded extends Webhook_Event {
|
||||
|
||||
/**
|
||||
* Processes the event.
|
||||
*
|
||||
* @throws API_Exception
|
||||
* @throws Authentication_Exception
|
||||
* @throws \Exception
|
||||
*
|
||||
* @since 2.11
|
||||
*/
|
||||
protected function process_event() {
|
||||
// Bail if this refund transaction already exists.
|
||||
if ( $this->refund_transaction_exists() ) {
|
||||
edd_debug_log( 'PayPal Commerce - Exiting webhook, as refund transaction already exists.' );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $this->get_order_from_refund();
|
||||
|
||||
if ( 'refunded' === $order->status ) {
|
||||
edd_debug_log( 'PayPal Commerce - Exiting webhook, as payment status is already refunded.' );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$order_amount = edd_get_payment_amount( $order->id );
|
||||
$refunded_amount = isset( $this->event->resource->amount->value ) ? $this->event->resource->amount->value : $order_amount;
|
||||
$currency = isset( $this->event->resource->amount->currency_code ) ? $this->event->resource->amount->currency_code : $order->currency;
|
||||
|
||||
/* Translators: %1$s - Amount refunded; %2$s - Original payment ID; %3$s - Refund transaction ID */
|
||||
$payment_note = sprintf(
|
||||
esc_html__( 'Amount: %1$s; Payment transaction ID: %2$s; Refund transaction ID: %3$s', 'easy-digital-downloads' ),
|
||||
edd_currency_filter( edd_format_amount( $refunded_amount ), $currency ),
|
||||
esc_html( $order->get_transaction_id() ),
|
||||
esc_html( $this->event->resource->id )
|
||||
);
|
||||
|
||||
// Partial refund.
|
||||
if ( (float) $refunded_amount < (float) $order_amount ) {
|
||||
edd_add_note( array(
|
||||
'object_type' => 'order',
|
||||
'object_id' => $order->id,
|
||||
'content' => __( 'Partial refund processed in PayPal.', 'easy-digital-downloads' ) . ' ' . $payment_note,
|
||||
) );
|
||||
edd_update_order_status( $order->id, 'partially_refunded' );
|
||||
} else {
|
||||
// Full refund.
|
||||
edd_add_note( array(
|
||||
'object_type' => 'order',
|
||||
'object_id' => $order->id,
|
||||
'content' => __( 'Full refund processed in PayPal.', 'easy-digital-downloads' ) . ' ' . $payment_note,
|
||||
) );
|
||||
edd_update_order_status( $order->id, 'refunded' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a transaction record exists for this refund.
|
||||
*
|
||||
* @since 3.0
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function refund_transaction_exists() {
|
||||
if ( ! isset( $this->event->resource->id ) ) {
|
||||
throw new \Exception( 'No resource ID found.', 200 );
|
||||
}
|
||||
|
||||
$transaction = edd_get_order_transaction_by( 'transaction_id', $this->event->resource->id );
|
||||
|
||||
return ! empty( $transaction );
|
||||
}
|
||||
}
|
@ -0,0 +1,278 @@
|
||||
<?php
|
||||
/**
|
||||
* Webhook Functions
|
||||
*
|
||||
* @package easy-digital-downloads
|
||||
* @subpackage Gateways\PayPal\Webhooks
|
||||
* @copyright Copyright (c) 2021, Sandhills Development, LLC
|
||||
* @license GPL2+
|
||||
* @since 2.11
|
||||
*/
|
||||
|
||||
namespace EDD\Gateways\PayPal\Webhooks;
|
||||
|
||||
use EDD\Gateways\PayPal\API;
|
||||
use EDD\Gateways\PayPal\Exceptions\API_Exception;
|
||||
use EDD\Gateways\PayPal\Exceptions\Authentication_Exception;
|
||||
|
||||
/**
|
||||
* Returns the webhook URL.
|
||||
*
|
||||
* @since 2.11
|
||||
* @return string
|
||||
*/
|
||||
function get_webhook_url() {
|
||||
return rest_url( Webhook_Handler::REST_NAMESPACE . '/' . Webhook_Handler::REST_ROUTE );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ID of the webhook.
|
||||
*
|
||||
* @param string $mode API mode. Either `sandbox` or `live`. If omitted, current store mode is used.
|
||||
*
|
||||
* @since 2.11
|
||||
* @return string|false
|
||||
*/
|
||||
function get_webhook_id( $mode = '' ) {
|
||||
if ( empty( $mode ) ) {
|
||||
$mode = edd_is_test_mode() ? API::MODE_SANDBOX : API::MODE_LIVE;
|
||||
}
|
||||
|
||||
return get_option( sanitize_key( 'edd_paypal_commerce_webhook_id_' . $mode ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of webhook events that EDD requires.
|
||||
*
|
||||
* @link https://developer.paypal.com/docs/api-basics/notifications/webhooks/event-names/#sales
|
||||
*
|
||||
* @todo Would be nice to use the EDD 3.0 registry for this at some point.
|
||||
*
|
||||
* @param string $mode Store mode. Either `sandbox` or `live`.
|
||||
*
|
||||
* @since 2.11
|
||||
* @return array
|
||||
*/
|
||||
function get_webhook_events( $mode = '' ) {
|
||||
$events = array(
|
||||
'PAYMENT.CAPTURE.COMPLETED' => '\\EDD\\Gateways\\PayPal\\Webhooks\\Events\\Payment_Capture_Completed',
|
||||
'PAYMENT.CAPTURE.DENIED' => '\\EDD\\Gateways\\PayPal\\Webhooks\\Events\\Payment_Capture_Denied',
|
||||
'PAYMENT.CAPTURE.REFUNDED' => '\\EDD\\Gateways\\PayPal\\Webhooks\\Events\\Payment_Capture_Refunded',
|
||||
'PAYMENT.CAPTURE.REVERSED' => '\\EDD\\Gateways\\PayPal\\Webhooks\\Events\\Payment_Capture_Refunded',
|
||||
);
|
||||
|
||||
/**
|
||||
* Filters the webhook events.
|
||||
*
|
||||
* @param array $events Array of events that PayPal will send webhooks for.
|
||||
* @param string $mode Mode the webhook is being created in. Either `sandbox` or `live`.
|
||||
*
|
||||
* @since 2.11
|
||||
*/
|
||||
return (array) apply_filters( 'edd_paypal_webhook_events', $events, $mode );
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a webhook.
|
||||
*
|
||||
* @param string $mode Store mode. Either `sandbox` or `live`. If omitted, current store mode is used.
|
||||
*
|
||||
* @return true
|
||||
* @throws API_Exception
|
||||
* @throws Authentication_Exception
|
||||
*/
|
||||
function create_webhook( $mode = '' ) {
|
||||
if ( ! is_ssl() ) {
|
||||
throw new API_Exception( __( 'An SSL certificate is required to create a PayPal webhook.', 'easy-digital-downloads' ) );
|
||||
}
|
||||
|
||||
if ( empty( $mode ) ) {
|
||||
$mode = edd_is_test_mode() ? API::MODE_SANDBOX : API::MODE_LIVE;
|
||||
}
|
||||
|
||||
$webhook_url = get_webhook_url();
|
||||
|
||||
$api = new API( $mode );
|
||||
|
||||
// First, list webhooks in case it's already added.
|
||||
try {
|
||||
$response = $api->make_request( 'v1/notifications/webhooks', array(), array(), 'GET' );
|
||||
if ( ! empty( $response->webhooks ) && is_array( $response->webhooks ) ) {
|
||||
foreach ( $response->webhooks as $webhook ) {
|
||||
if ( ! empty( $webook->id ) && ! empty( $webhook->url ) && $webhook_url === $webhook->url ) {
|
||||
update_option( sanitize_key( 'edd_paypal_commerce_webhook_id_' . $mode ), sanitize_text_field( $webhook->id ) );
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch ( \Exception $e ) {
|
||||
// Continue to webhook creation.
|
||||
}
|
||||
|
||||
$event_types = array();
|
||||
|
||||
foreach ( array_keys( get_webhook_events( $mode ) ) as $event ) {
|
||||
$event_types[] = array( 'name' => $event );
|
||||
}
|
||||
|
||||
$response = $api->make_request( 'v1/notifications/webhooks', array(
|
||||
'url' => $webhook_url,
|
||||
'event_types' => $event_types
|
||||
) );
|
||||
|
||||
if ( 201 !== $api->last_response_code ) {
|
||||
throw new API_Exception( sprintf(
|
||||
/* Translators: %d - HTTP response code; %s - Full response from the API. */
|
||||
__( 'Invalid response code %d while creating webhook. Response: %s', 'easy-digital-downloads' ),
|
||||
$api->last_response_code,
|
||||
json_encode( $response )
|
||||
) );
|
||||
}
|
||||
|
||||
if ( empty( $response->id ) ) {
|
||||
throw new API_Exception( __( 'Unexpected response from PayPal.', 'easy-digital-downloads' ) );
|
||||
}
|
||||
|
||||
update_option( sanitize_key( 'edd_paypal_commerce_webhook_id_' . $mode ), sanitize_text_field( $response->id ) );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs the webhook with expected data. This replaces the webhook URL and event types with
|
||||
* what EDD expects them to be. This can be used when the events need to be updated in
|
||||
* the event that some are missing.
|
||||
*
|
||||
* @param string $mode Either `sandbox` or `live` mode. If omitted, current store mode is used.
|
||||
*
|
||||
* @since 2.11
|
||||
* @return true
|
||||
* @throws API_Exception
|
||||
* @throws Authentication_Exception
|
||||
*/
|
||||
function sync_webhook( $mode = '' ) {
|
||||
if ( empty( $mode ) ) {
|
||||
$mode = edd_is_test_mode() ? API::MODE_SANDBOX : API::MODE_LIVE;
|
||||
}
|
||||
|
||||
$webhook_id = get_webhook_id( $mode );
|
||||
if ( empty( $webhook_id ) ) {
|
||||
throw new \Exception( esc_html__( 'Webhook not configured.', 'easy-digital-downloads' ) );
|
||||
}
|
||||
|
||||
$event_types = array();
|
||||
foreach ( array_keys( get_webhook_events( $mode ) ) as $event ) {
|
||||
$event_types[] = array( 'name' => $event );
|
||||
}
|
||||
|
||||
$new_data = array(
|
||||
array(
|
||||
'op' => 'replace',
|
||||
'path' => '/url',
|
||||
'value' => get_webhook_url()
|
||||
),
|
||||
array(
|
||||
'op' => 'replace',
|
||||
'path' => '/event_types',
|
||||
'value' => $event_types
|
||||
)
|
||||
);
|
||||
|
||||
$api = new API( $mode );
|
||||
$response = $api->make_request( 'v1/notifications/webhooks/' . urlencode( $webhook_id ), $new_data, array(), 'PATCH' );
|
||||
if ( 400 === $api->last_response_code && isset( $response->name ) && 'WEBHOOK_PATCH_REQUEST_NO_CHANGE' === $response->name ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( 200 !== $api->last_response_code ) {
|
||||
throw new API_Exception( sprintf(
|
||||
/* Translators: %d - HTTP response code; %s - Full response from the API. */
|
||||
__( 'Invalid response code %d while syncing webhook. Response: %s', 'easy-digital-downloads' ),
|
||||
$api->last_response_code,
|
||||
json_encode( $response )
|
||||
) );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves information about the webhook EDD created.
|
||||
*
|
||||
* @param string $mode Mode to get the webhook in. If omitted, current store mode is used.
|
||||
*
|
||||
* @return object|false Webhook object on success, false if there is no webhook set up.
|
||||
* @throws API_Exception
|
||||
* @throws Authentication_Exception
|
||||
*/
|
||||
function get_webhook_details( $mode = '' ) {
|
||||
if ( empty( $mode ) ) {
|
||||
$mode = edd_is_test_mode() ? API::MODE_SANDBOX : API::MODE_LIVE;
|
||||
}
|
||||
|
||||
$webhook_id = get_option( sanitize_key( 'edd_paypal_commerce_webhook_id_' . $mode ) );
|
||||
|
||||
// Bail if webhook was never set.
|
||||
if ( ! $webhook_id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$api = new API( $mode );
|
||||
$response = $api->make_request( 'v1/notifications/webhooks/' . urlencode( $webhook_id ), array(), array(), 'GET' );
|
||||
if ( 200 !== $api->last_response_code ) {
|
||||
if ( 404 === $api->last_response_code ) {
|
||||
throw new API_Exception(
|
||||
__( 'Your store is currently not receiving webhook notifications, create the webhooks to reconnect.', 'easy-digital-downloads' )
|
||||
);
|
||||
} else {
|
||||
throw new API_Exception( sprintf(
|
||||
/* Translators: %d - HTTP response code. */
|
||||
__( 'Invalid response code %d while retrieving webhook details.', 'easy-digital-downloads' ),
|
||||
$api->last_response_code
|
||||
) );
|
||||
}
|
||||
}
|
||||
|
||||
if ( empty( $response->id ) ) {
|
||||
throw new API_Exception( __( 'Unexpected response from PayPal when retrieving webhook details.', 'easy-digital-downloads' ) );
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the webhook.
|
||||
*
|
||||
* @since 2.11
|
||||
*
|
||||
* @param string $mode
|
||||
*
|
||||
* @throws API_Exception
|
||||
* @throws Authentication_Exception
|
||||
*/
|
||||
function delete_webhook( $mode = '' ) {
|
||||
if ( empty( $mode ) ) {
|
||||
$mode = edd_is_test_mode() ? API::MODE_SANDBOX : API::MODE_LIVE;
|
||||
}
|
||||
|
||||
$webhook_name = sanitize_key( 'edd_paypal_commerce_webhook_id_' . $mode );
|
||||
$webhook_id = get_option( $webhook_name );
|
||||
|
||||
// Bail if webhook was never set.
|
||||
if ( ! $webhook_id ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$api = new API( $mode );
|
||||
|
||||
$api->make_request( 'v1/notifications/webhooks/' . urlencode( $webhook_id ), array(), array(), 'DELETE' );
|
||||
|
||||
if ( 204 !== $api->last_response_code ) {
|
||||
throw new API_Exception( sprintf(
|
||||
/* Translators: %d - HTTP response code. */
|
||||
__( 'Invalid response code %d while deleting webhook.', 'easy-digital-downloads' ),
|
||||
$api->last_response_code
|
||||
) );
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user