installed plugin Easy Digital Downloads version 3.1.0.3

This commit is contained in:
2022-11-27 15:03:07 +00:00
committed by Gitium
parent 555673545b
commit c5dce2cec6
1200 changed files with 238970 additions and 0 deletions

View File

@ -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();
} );

View File

@ -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();
}
}

View File

@ -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 );
}
}

View File

@ -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' );
}
}

View File

@ -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' ),
) );
}
}

View File

@ -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 );
}
}

View File

@ -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
) );
}
}