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,772 @@
<?php
/**
* PayPal Commerce Connect
*
* @package easy-digital-downloads
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal\Admin;
use EDD\Gateways\PayPal;
use EDD\Gateways\PayPal\AccountStatusValidator;
use EDD\Gateways\PayPal\API;
if ( ! defined( 'EDD_PAYPAL_PARTNER_CONNECT_URL' ) ) {
define( 'EDD_PAYPAL_PARTNER_CONNECT_URL', 'https://easydigitaldownloads.com/wp-json/paypal-connect/v1/' );
}
/**
* Returns the content for the PayPal Commerce Connect fields.
*
* If the account is not yet connected, the user is shown a "Connect with PayPal" button.
* If they are connected, their account details are shown instead.
*
* @since 2.11
* @return string
*/
function connect_settings_field() {
$is_connected = PayPal\has_rest_api_connection();
$mode = edd_is_test_mode() ? __( 'sandbox', 'easy-digital-downloads' ) : __( 'live', 'easy-digital-downloads' );
ob_start();
if ( ! $is_connected ) {
/**
* Show Connect
*/
/*
* If we have Partner details but no REST credentials then that most likely means
* PayPal wasn't opened in the modal. We'll show an error message about popups.
*/
if ( get_partner_details() ) {
?>
<div class="notice notice-error inline">
<p>
<?php
echo wp_kses( sprintf(
/* Translators: %1$s opening <strong> tag; %2$s closing </strong> tag */
__( '%1$sConnection failure:%2$s Please ensure browser popups are enabled then click the button below to connect again. If you continue to experience this error, please contact support.', 'easy-digital-downloads' ),
'<strong>',
'</strong>'
), array( 'strong' => array() ) );
?>
</p>
</div>
<?php
}
?>
<button type="button" id="edd-paypal-commerce-connect" class="button" data-nonce="<?php echo esc_attr( wp_create_nonce( 'edd_process_paypal_connect' ) ); ?>">
<?php
/* Translators: %s - the store mode, either `sandbox` or `live` */
printf( esc_html__( 'Connect with PayPal in %s mode', 'easy-digital-downloads' ), esc_html( $mode ) );
?>
</button>
<a href="#" target="_blank" id="edd-paypal-commerce-link" class="edd-hidden" data-paypal-onboard-complete="eddPayPalOnboardingCallback" data-paypal-button="true">
<?php esc_html_e( 'Sign up for PayPal', 'easy-digital-downloads' ); ?>
</a>
<div id="edd-paypal-commerce-errors"></div>
<?php
} else {
/**
* Show Account Info & Disconnect
*/
?>
<div id="edd-paypal-commerce-connect-wrap" class="edd-paypal-connect-account-info notice inline" data-nonce="<?php echo esc_attr( wp_create_nonce( 'edd_paypal_account_information' ) ); ?>">
<p>
<em><?php esc_html_e( 'Retrieving account information...', 'easy-digital-downloads' ); ?></em>
<span class="spinner is-active"></span>
</p>
</div>
<div id="edd-paypal-disconnect"></div>
<?php
}
?>
<?php
return ob_get_clean();
}
/**
* AJAX handler for processing the PayPal Connection.
*
* @since 2.11
* @return void
*/
function process_connect() {
// This validates the nonce.
check_ajax_referer( 'edd_process_paypal_connect' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( __( 'You do not have permission to perform this action.', 'easy-digital-downloads' ) );
}
$mode = edd_is_test_mode() ? API::MODE_SANDBOX : API::MODE_LIVE;
$response = wp_remote_post( EDD_PAYPAL_PARTNER_CONNECT_URL . 'signup-link', array(
'headers' => array(
'Content-Type' => 'application/json',
),
'body' => json_encode( array(
'mode' => $mode,
'country_code' => edd_get_shop_country(),
'currency_code' => edd_get_currency(),
'return_url' => get_settings_url()
) )
) );
if ( is_wp_error( $response ) ) {
wp_send_json_error( $response->get_error_message() );
}
$code = wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ) );
if ( 200 !== intval( $code ) ) {
wp_send_json_error( sprintf(
/* Translators: %d - HTTP response code; %s - Response from the API */
__( 'Unexpected response code: %d. Error: %s', 'easy-digital-downloads' ),
$code,
json_encode( $body )
) );
}
if ( empty( $body->signupLink ) || empty( $body->nonce ) ) {
wp_send_json_error( __( 'An unexpected error occurred.', 'easy-digital-downloads' ) );
}
/**
* We need to store this temporarily so we can use the nonce again in the next request.
*
* @see get_access_token()
*/
update_option( 'edd_paypal_commerce_connect_details_' . $mode, json_encode( $body ) );
wp_send_json_success( $body );
}
add_action( 'wp_ajax_edd_paypal_commerce_connect', __NAMESPACE__ . '\process_connect' );
/**
* AJAX handler for processing the PayPal Reconnect.
*
* @since 3.1.0.3
* @return void
*/
function process_reconnect() {
// This validates the nonce.
check_ajax_referer( 'edd_process_paypal_connect' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( __( 'You do not have permission to perform this action.', 'easy-digital-downloads' ) );
}
$mode = edd_is_test_mode() ? API::MODE_SANDBOX : API::MODE_LIVE;
/**
* Make sure we still have connection details from the previously connected site.
*/
$connection_details = get_option( 'edd_paypal_commerce_connect_details_' . $mode );
if ( empty( $connection_details ) ) {
// Somehow we ended up here, but now that we're in an invalid state, remove all settings so we can fully reset.
delete_option( 'edd_paypal_commerce_connect_details_' . $mode );
delete_option( 'edd_paypal_commerce_webhook_id_' . $mode );
delete_option( 'edd_paypal_' . $mode . '_merchant_details' );
wp_send_json_error( __( 'Failure reconnecting to PayPal. Please try again', 'easy-digital-downloads' ) );
}
try {
PayPal\Webhooks\create_webhook( $mode );
} catch ( \Exception $e ) {
$message = esc_html__( 'Your account has been successfully reconnected, but an error occurred while creating a webhook.', 'easy-digital-downloads' );
}
wp_safe_redirect( esc_url_raw( get_settings_url() ) );
}
add_action( 'wp_ajax_edd_paypal_commerce_reconnect', __NAMESPACE__ . '\process_reconnect' );
/**
* Retrieves partner Connect details for the given mode.
*
* @param string $mode Store mode. If omitted, current mode is used.
*
* @return array|null
*/
function get_partner_details( $mode = '' ) {
if ( ! $mode ) {
$mode = edd_is_test_mode() ? API::MODE_SANDBOX : API::MODE_LIVE;
}
return json_decode( get_option( 'edd_paypal_commerce_connect_details_' . $mode ) );
}
/**
* AJAX handler for retrieving a one-time access token, then used to retrieve
* the seller's API credentials.
*
* @since 2.11
* @return void
*/
function get_and_save_credentials() {
// This validates the nonce.
check_ajax_referer( 'edd_process_paypal_connect' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( __( 'You do not have permission to perform this action.', 'easy-digital-downloads' ) );
}
if ( empty( $_POST['auth_code'] ) || empty( $_POST['share_id'] ) ) {
wp_send_json_error( __( 'Missing PayPal authentication information. Please try again.', 'easy-digital-downloads' ) );
}
$mode = edd_is_test_mode() ? PayPal\API::MODE_SANDBOX : PayPal\API::MODE_LIVE;
$partner_details = get_partner_details( $mode );
if ( empty( $partner_details->nonce ) ) {
wp_send_json_error( __( 'Missing nonce. Please refresh the page and try again.', 'easy-digital-downloads' ) );
}
$paypal_subdomain = edd_is_test_mode() ? '.sandbox' : '';
$api_url = 'https://api-m' . $paypal_subdomain . '.paypal.com/';
/*
* First get a temporary access token from PayPal.
*/
$response = wp_remote_post( $api_url . 'v1/oauth2/token', array(
'headers' => array(
'Content-Type' => 'application/x-www-form-urlencoded',
'Authorization' => sprintf( 'Basic %s', base64_encode( $_POST['share_id'] ) ),
'timeout' => 15
),
'body' => array(
'grant_type' => 'authorization_code',
'code' => $_POST['auth_code'],
'code_verifier' => $partner_details->nonce
)
) );
if ( is_wp_error( $response ) ) {
wp_send_json_error( $response->get_error_message() );
}
$code = wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ) );
if ( empty( $body->access_token ) ) {
wp_send_json_error( sprintf(
/* Translators: %d - HTTP response code */
__( 'Unexpected response from PayPal while generating token. Response code: %d. Please try again.', 'easy-digital-downloads' ),
$code
) );
}
/*
* Now we can use this access token to fetch the seller's credentials for all future
* API requests.
*/
$response = wp_remote_get( $api_url . 'v1/customer/partners/' . urlencode( \EDD\Gateways\PayPal\get_partner_merchant_id( $mode ) ) . '/merchant-integrations/credentials/', array(
'headers' => array(
'Authorization' => sprintf( 'Bearer %s', $body->access_token ),
'Content-Type' => 'application/json',
'timeout' => 15
)
) );
if ( is_wp_error( $response ) ) {
wp_send_json_error( $response->get_error_message() );
}
$code = wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ) );
if ( empty( $body->client_id ) || empty( $body->client_secret ) ) {
wp_send_json_error( sprintf(
/* Translators: %d - HTTP response code */
__( 'Unexpected response from PayPal. Response code: %d. Please try again.', 'easy-digital-downloads' ),
$code
) );
}
edd_update_option( 'paypal_' . $mode . '_client_id', sanitize_text_field( $body->client_id ) );
edd_update_option( 'paypal_' . $mode . '_client_secret', sanitize_text_field( $body->client_secret ) );
$message = esc_html__( 'Successfully connected.', 'easy-digital-downloads' );
try {
PayPal\Webhooks\create_webhook( $mode );
} catch ( \Exception $e ) {
$message = esc_html__( 'Your account has been successfully connected, but an error occurred while creating a webhook.', 'easy-digital-downloads' );
}
/**
* Triggers when an account is successfully connected to PayPal.
*
* @param string $mode The mode that the account was connected in. Either `sandbox` or `live`.
*
* @since 2.11
*/
do_action( 'edd_paypal_commerce_connected', $mode );
wp_send_json_success( $message );
}
add_action( 'wp_ajax_edd_paypal_commerce_get_access_token', __NAMESPACE__ . '\get_and_save_credentials' );
/**
* Verifies the connected account.
*
* @since 2.11
* @return void
*/
function get_account_info() {
check_ajax_referer( 'edd_paypal_account_information' );
if ( ! current_user_can( 'manage_shop_settings' ) ) {
wp_send_json_error( wpautop( __( 'You do not have permission to perform this action.', 'easy-digital-downloads' ) ) );
}
try {
$status = 'success';
$account_status = '';
$actions = array(
'refresh_merchant' => '<button type="button" class="button edd-paypal-connect-action" data-nonce="' . esc_attr( wp_create_nonce( 'edd_check_merchant_status' ) ) . '" data-action="edd_paypal_commerce_check_merchant_status">' . esc_html__( 'Re-Check Payment Status', 'easy-digital-downloads' ) . '</button>',
'webhook' => '<button type="button" class="button edd-paypal-connect-action" data-nonce="' . esc_attr( wp_create_nonce( 'edd_update_paypal_webhook' ) ) . '" data-action="edd_paypal_commerce_update_webhook">' . esc_html__( 'Sync Webhook', 'easy-digital-downloads' ) . '</button>'
);
$disconnect_links = array(
'disconnect' => '<a class="button-secondary" id="edd-paypal-disconnect-link" href="' . esc_url( get_disconnect_url() ) . '">' . __( "Disconnect webhooks from PayPal", "easy-digital-downloads" ) . '</a>',
'delete' => '<a class="button-secondary" id="edd-paypal-delete-link" href="' . esc_url( get_delete_url() ) . '">' . __( "Delete connection with PayPal", "easy-digital-downloads" ) . '</a>',
);
$validator = new AccountStatusValidator();
$validator->check();
/*
* 1. Check REST API credentials
*/
$rest_api_message = '<strong>' . __( 'API:', 'easy-digital-downloads' ) . '</strong>' . ' ';
if ( $validator->errors_for_credentials->errors ) {
$rest_api_dashicon = 'no';
$status = 'error';
$rest_api_message .= $validator->errors_for_credentials->get_error_message();
} else {
$rest_api_dashicon = 'yes';
$mode_string = edd_is_test_mode() ? __( 'sandbox', 'easy-digital-downloads' ) : __( 'live', 'easy-digital-downloads' );
/* Translators: %s - the connected mode, either `sandbox` or `live` */
$rest_api_message .= sprintf( __( 'Your PayPal account is successfully connected in %s mode.', 'easy-digital-downloads' ), $mode_string );
}
ob_start();
?>
<li>
<span class="dashicons dashicons-<?php echo esc_attr( $rest_api_dashicon ); ?>"></span>
<span><?php echo wp_kses( $rest_api_message, array( 'strong' => array() ) ); ?></span>
</li>
<?php
$account_status .= ob_get_clean();
/*
* 2. Check merchant account
*/
$merchant_account_message = '<strong>' . __( 'Payment Status:', 'easy-digital-downloads' ) . '</strong>' . ' ';
if ( $validator->errors_for_merchant_account->errors ) {
$merchant_dashicon = 'no';
$status = 'error';
$merchant_account_message .= __( 'You need to address the following issues before you can start receiving payments:', 'easy-digital-downloads' );
// We can only refresh the status if we have a merchant ID.
if ( in_array( 'missing_merchant_details', $validator->errors_for_merchant_account->get_error_codes() ) ) {
unset( $actions['refresh_merchant'] );
}
} else {
$merchant_dashicon = 'yes';
$merchant_account_message .= __( 'Ready to accept payments.', 'easy-digital-downloads' );
}
ob_start();
?>
<li>
<span class="dashicons dashicons-<?php echo esc_attr( $merchant_dashicon ); ?>"></span>
<span><?php echo wp_kses_post( $merchant_account_message ); ?></span>
<?php if ( $validator->errors_for_merchant_account->errors ) : ?>
<ul>
<?php foreach ( $validator->errors_for_merchant_account->get_error_codes() as $code ) : ?>
<li><?php echo wp_kses( $validator->errors_for_merchant_account->get_error_message( $code ), array( 'strong' => array() ) ); ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</li>
<?php
$account_status .= ob_get_clean();
/*
* 3. Webhooks
*/
$webhook_message = '<strong>' . __( 'Webhook:', 'easy-digital-downloads' ) . '</strong>' . ' ';
if ( $validator->errors_for_webhook->errors ) {
$webhook_dashicon = 'no';
$status = ( 'success' === $status ) ? 'warning' : $status;
$webhook_message .= $validator->errors_for_webhook->get_error_message();
if ( in_array( 'webhook_missing', $validator->errors_for_webhook->get_error_codes() ) ) {
unset( $disconnect_links['disconnect'] );
$actions['webhook'] = '<button type="button" class="button edd-paypal-connect-action" data-nonce="' . esc_attr( wp_create_nonce( 'edd_create_paypal_webhook' ) ) . '" data-action="edd_paypal_commerce_create_webhook">' . esc_html__( 'Create Webhooks', 'easy-digital-downloads' ) . '</button>';
}
} else {
unset( $disconnect_links['delete'] );
$webhook_dashicon = 'yes';
$webhook_message .= __( 'Webhook successfully configured for the following events:', 'easy-digital-downloads' );
}
ob_start();
?>
<li>
<span class="dashicons dashicons-<?php echo esc_attr( $webhook_dashicon ); ?>"></span>
<span><?php echo wp_kses( $webhook_message, array( 'strong' => array() ) ); ?></span>
<?php if ( $validator->webhook ) : ?>
<ul class="edd-paypal-webhook-events">
<?php foreach ( array_keys( PayPal\Webhooks\get_webhook_events() ) as $event_name ) : ?>
<li>
<span class="dashicons dashicons-<?php echo in_array( $event_name, $validator->enabled_webhook_events ) ? 'yes' : 'no'; ?>"></span>
<span class="edd-paypal-webhook-event-name"><?php echo esc_html( $event_name ); ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</li>
<?php
$account_status .= ob_get_clean();
if ( ! edd_is_gateway_active( 'paypal_commerce' ) ) {
$account_status .= sprintf(
/* Translators: %1$s opening anchor tag; %2$s closing anchor tag; %3$s: opening line item/status/strong tags; %4$s closing strong tag; %5$s: closing list item tag */
__( '%3$sGateway Status: %4$s PayPal is not currently active. %1$sEnable PayPal%2$s in the general gateway settings to start using it.%5$s', 'easy-digital-downloads' ),
'<a href="' . esc_url( admin_url( 'edit.php?post_type=download&page=edd-settings&tab=gateways&section=main' ) ) . '">',
'</a>',
'<li><span class="dashicons dashicons-no"></span><strong>',
'</strong>',
'</li>'
);
}
wp_send_json_success( array(
'status' => $status,
'account_status' => '<ul class="edd-paypal-account-status">' . $account_status . '</ul>',
'webhook_object' => isset( $validator ) ? $validator->webhook : null,
'actions' => array_values( $actions ),
'disconnect_links' => array_values( $disconnect_links ),
) );
} catch ( \Exception $e ) {
wp_send_json_error( array(
'status' => isset( $status ) ? $status : 'error',
'message' => wpautop( $e->getMessage() )
) );
}
}
add_action( 'wp_ajax_edd_paypal_commerce_get_account_info', __NAMESPACE__ . '\get_account_info' );
/**
* Returns the URL for disconnecting from PayPal Commerce.
*
* @since 2.11
* @return string
*/
function get_disconnect_url() {
return wp_nonce_url(
add_query_arg(
array(
'edd_action' => 'disconnect_paypal_commerce'
),
admin_url()
),
'edd_disconnect_paypal_commerce'
);
}
/**
* Returns the URL for deleting the app PayPal Commerce.
*
* @since 3.1.0.3
* @return string
*/
function get_delete_url() {
return wp_nonce_url(
add_query_arg(
array(
'edd_action' => 'delete_paypal_commerce'
),
admin_url()
),
'edd_delete_paypal_commerce'
);
}
/**
* Disconnects from PayPal in the current mode.
*
* @since 2.11
* @return void
*/
function process_disconnect() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to perform this action.', 'easy-digital-downloads' ), esc_html__( 'Error', 'easy-digital-downloads' ), array( 'response' => 403 ) );
}
if ( empty( $_GET['_wpnonce'] ) || ! wp_verify_nonce( $_GET['_wpnonce'], 'edd_disconnect_paypal_commerce' ) ) {
wp_die( esc_html__( 'You do not have permission to perform this action.', 'easy-digital-downloads' ), esc_html__( 'Error', 'easy-digital-downloads' ), array( 'response' => 403 ) );
}
$mode = edd_is_test_mode() ? PayPal\API::MODE_SANDBOX : PayPal\API::MODE_LIVE;
try {
$api = new PayPal\API();
try {
// Disconnect the webhook.
// This is in another try/catch because we want to delete the token cache (below) even if this fails.
// This only deletes the webhooks in PayPal, we do not remove the webhook ID in EDD until we delete the connection.
PayPal\Webhooks\delete_webhook( $mode );
} catch ( \Exception $e ) {
}
// Also delete the token cache key, to ensure we fetch a fresh one if they connect to a different account later.
delete_option( $api->token_cache_key );
} catch ( \Exception $e ) {
}
wp_safe_redirect( esc_url_raw( get_settings_url() ) );
exit;
}
add_action( 'edd_disconnect_paypal_commerce', __NAMESPACE__ . '\process_disconnect' );
/**
* Fully deletes past Merchant Information from PayPal in the current mode.
*
* @since 3.1.0.3
* @return void
*/
function process_delete() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to perform this action.', 'easy-digital-downloads' ), esc_html__( 'Error', 'easy-digital-downloads' ), array( 'response' => 403 ) );
}
if ( empty( $_GET['_wpnonce'] ) || ! wp_verify_nonce( $_GET['_wpnonce'], 'edd_delete_paypal_commerce' ) ) {
wp_die( esc_html__( 'You do not have permission to perform this action.', 'easy-digital-downloads' ), esc_html__( 'Error', 'easy-digital-downloads' ), array( 'response' => 403 ) );
}
$mode = edd_is_test_mode() ? PayPal\API::MODE_SANDBOX : PayPal\API::MODE_LIVE;
// Delete merchant information.
delete_option( 'edd_paypal_' . $mode . '_merchant_details' );
// Delete partner connect information.
delete_option( 'edd_paypal_commerce_connect_details_' . $mode );
try {
$api = new PayPal\API();
try {
// Disconnect the webhook.
// This is in another try/catch because we want to delete the token cache (below) even if this fails.
// This only deletes the webhooks in PayPal, we do not remove the webhook ID in EDD until we delete the connection.
PayPal\Webhooks\delete_webhook( $mode );
} catch ( \Exception $e ) {
}
// Also delete the token cache key, to ensure we fetch a fresh one if they connect to a different account later.
delete_option( $api->token_cache_key );
} catch ( \Exception $e ) {
}
// Now delete our webhook ID.
delete_option( sanitize_key( 'edd_paypal_commerce_webhook_id_' . $mode ) );
// Delete API credentials.
$edd_settings_to_delete = array(
'paypal_' . $mode . '_client_id',
'paypal_' . $mode . '_client_secret',
);
foreach ( $edd_settings_to_delete as $option_name ) {
edd_delete_option( $option_name );
}
wp_safe_redirect( esc_url_raw( get_settings_url() ) );
exit;
}
add_action( 'edd_delete_paypal_commerce', __NAMESPACE__ . '\process_delete' );
/**
* AJAX callback for refreshing payment status.
*
* @since 2.11
*/
function refresh_merchant_status() {
check_ajax_referer( 'edd_check_merchant_status' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( esc_html__( 'You do not have permission to perform this action.', 'easy-digital-downloads' ) );
}
$merchant_details = PayPal\MerchantAccount::retrieve();
try {
if ( empty( $merchant_details->merchant_id ) ) {
throw new \Exception( __( 'No merchant ID saved. Please reconnect to PayPal.', 'easy-digital-downloads' ) );
}
$partner_details = get_partner_details();
$nonce = isset( $partner_details->nonce ) ? $partner_details->nonce : null;
$new_details = get_merchant_status( $merchant_details->merchant_id, $nonce );
$merchant_account = new PayPal\MerchantAccount( $new_details );
$merchant_account->save();
wp_send_json_success();
} catch ( \Exception $e ) {
wp_send_json_error( esc_html( $e->getMessage() ) );
}
}
add_action( 'wp_ajax_edd_paypal_commerce_check_merchant_status', __NAMESPACE__ . '\refresh_merchant_status' );
/**
* AJAX callback for creating a webhook.
*
* @since 2.11
*/
function create_webhook() {
check_ajax_referer( 'edd_create_paypal_webhook' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( esc_html__( 'You do not have permission to perform this action.', 'easy-digital-downloads' ) );
}
try {
PayPal\Webhooks\create_webhook();
wp_send_json_success();
} catch ( \Exception $e ) {
wp_send_json_error( esc_html( $e->getMessage() ) );
}
}
add_action( 'wp_ajax_edd_paypal_commerce_create_webhook', __NAMESPACE__ . '\create_webhook' );
/**
* AJAX callback for syncing a webhook. This is used to fix issues with missing events.
*
* @since 2.11
*/
function update_webhook() {
check_ajax_referer( 'edd_update_paypal_webhook' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( esc_html__( 'You do not have permission to perform this action.', 'easy-digital-downloads' ) );
}
try {
PayPal\Webhooks\sync_webhook();
wp_send_json_success();
} catch ( \Exception $e ) {
wp_send_json_error( esc_html( $e->getMessage() ) );
}
}
add_action( 'wp_ajax_edd_paypal_commerce_update_webhook', __NAMESPACE__ . '\update_webhook' );
/**
* PayPal Redirect Callback
*
* This processes after the merchant is redirected from PayPal. We immediately
* check their seller status via partner connect and save their merchant status.
* The user is then redirected back to the settings page.
*
* @since 2.11
*/
add_action( 'admin_init', function () {
if ( ! isset( $_GET['merchantIdInPayPal'] ) || ! edd_is_admin_page( 'settings' ) ) {
return;
}
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( __( 'You do not have permission to perform this action.', 'easy-digital-downloads' ), __( 'Error', 'easy-digital-downloads' ), array( 'response' => 403 ) );
}
edd_debug_log( 'PayPal Connect - Checking merchant status.' );
$merchant_id = urldecode( $_GET['merchantIdInPayPal'] );
try {
$details = get_merchant_status( $merchant_id );
edd_debug_log( 'PayPal Connect - Successfully retrieved merchant status.' );
} catch ( \Exception $e ) {
/*
* This won't be enough to actually validate the merchant status, but we want to ensure
* we save the merchant ID no matter what.
*/
$details = array(
'merchant_id' => $merchant_id
);
edd_debug_log( sprintf( 'PayPal Connect - Failed to retrieve merchant status from PayPal. Error: %s', $e->getMessage() ) );
}
$merchant_account = new PayPal\MerchantAccount( $details );
$merchant_account->save();
wp_safe_redirect( esc_url_raw( get_settings_url() ) );
exit;
} );
/**
* Retrieves the merchant's status in PayPal.
*
* @param string $merchant_id
* @param string $nonce
*
* @return array
* @throws PayPal\Exceptions\API_Exception
*/
function get_merchant_status( $merchant_id, $nonce = '' ) {
$response = wp_remote_post( EDD_PAYPAL_PARTNER_CONNECT_URL . 'merchant-status', array(
'headers' => array(
'Content-Type' => 'application/json',
),
'body' => json_encode( array(
'mode' => edd_is_test_mode() ? API::MODE_SANDBOX : API::MODE_LIVE,
'merchant_id' => $merchant_id,
'nonce' => $nonce
) )
) );
$response_code = wp_remote_retrieve_response_code( $response );
$response_body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== (int) $response_code ) {
if ( ! empty( $response_body['error'] ) ) {
$error_message = $response_body['error'];
} else {
$error_message = sprintf(
'Invalid HTTP response code: %d. Response: %s',
$response_code,
wp_remote_retrieve_body( $response )
);
}
throw new PayPal\Exceptions\API_Exception( $error_message, $response_code );
}
return $response_body;
}

View File

@ -0,0 +1,66 @@
<?php
/**
* PayPal Admin Notices
*
* @package easy-digital-downloads
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal\Admin;
add_action( 'admin_notices', function () {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
// Bail if this notice has already been dismissed.
if ( get_user_meta( get_current_user_id(), '_edd_paypal_commerce_dismissed' ) ) {
return;
}
$enabled_gateways = array_keys( edd_get_enabled_payment_gateways() );
$enabled_gateways = array_diff( $enabled_gateways, array( 'paypal_commerce' ) );
// Show a notice if any PayPal gateway is enabled, other than PayPal Commerce.
$paypal_gateways = array_filter( $enabled_gateways, function( $gateway ) {
return false !== strpos( strtolower( $gateway ), 'paypal' );
} );
if ( ! $paypal_gateways ) {
return;
}
$dismiss_url = wp_nonce_url( add_query_arg( array(
'edd_action' => 'dismiss_notices',
'edd_notice' => 'paypal_commerce'
) ), 'edd_notice_nonce' );
$setup_url = add_query_arg( array(
'post_type' => 'download',
'page' => 'edd-settings',
'tab' => 'gateways',
'section' => 'paypal_commerce'
), admin_url( 'edit.php' ) );
?>
<div class="notice notice-info">
<h2><?php esc_html_e( 'Enable the new PayPal gateway for Easy Digital Downloads', 'easy-digital-downloads' ); ?></h2>
<p>
<?php
echo wp_kses( sprintf(
/* Translators: %1$s opening anchor tag; %2$s closing anchor tag */
__( 'A new, improved PayPal experience is now available in Easy Digital Downloads. You can learn more about the new integration in %1$sour documentation%2$s.', 'easy-digital-downloads' ),
'<a href="https://docs.easydigitaldownloads.com/article/2410-paypal#migration" target="_blank">',
'</a>'
), array( 'a' => array( 'href' => true, 'target' => true ) ) );
?>
</p>
<p>
<a href="<?php echo esc_url( $setup_url ); ?>" class="button button-primary"><?php esc_html_e( 'Activate the New PayPal', 'easy-digital-downloads' ); ?></a>
<a href="<?php echo esc_url( $dismiss_url ); ?>" class="button"><?php esc_html_e( 'Dismiss Notice', 'easy-digital-downloads' ); ?></a>
</p>
</div>
<?php
} );

View File

@ -0,0 +1,38 @@
<?php
/**
* PayPal Commerce Admin Scripts
*
* @package easy-digital-downloads
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal\Admin;
/**
* Enqueue PayPal connect admin JS.
*
* @since 2.11
*/
function enqueue_connect_scripts() {
if ( edd_is_admin_page( 'settings' ) && isset( $_GET['section'] ) && 'paypal_commerce' === $_GET['section'] ) {
\EDD\Gateways\PayPal\maybe_enqueue_polyfills();
$subdomain = edd_is_test_mode() ? 'sandbox.' : '';
wp_enqueue_script(
'sandhills-paypal-partner-js',
'https://www.' . $subdomain . 'paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js',
array(),
null,
true
);
wp_localize_script( 'edd-admin-settings', 'eddPayPalConnectVars', array(
'defaultError' => esc_html__( 'An unexpected error occurred. Please refresh the page and try again.', 'easy-digital-downloads' )
) );
}
}
add_action( 'admin_enqueue_scripts', __NAMESPACE__ . '\enqueue_connect_scripts' );

View File

@ -0,0 +1,159 @@
<?php
/**
* PayPal Settings
*
* @package easy-digital-downloads
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal\Admin;
use EDD\Gateways\PayPal;
/**
* Returns the URL to the PayPal Commerce settings page.
*
* @since 2.11
*
* @return string
*/
function get_settings_url() {
return admin_url( 'edit.php?post_type=download&page=edd-settings&tab=gateways&section=paypal_commerce' );
}
/**
* Register the PayPal Standard gateway subsection
*
* @param array $gateway_sections Current Gateway Tab subsections
*
* @since 2.11
* @return array Gateway subsections with PayPal Standard
*/
function register_paypal_gateway_section( $gateway_sections ) {
$gateway_sections['paypal_commerce'] = __( 'PayPal', 'easy-digital-downloads' );
return $gateway_sections;
}
add_filter( 'edd_settings_sections_gateways', __NAMESPACE__ . '\register_paypal_gateway_section', 1, 1 );
/**
* Registers the PayPal Standard settings for the PayPal Standard subsection
*
* @param array $gateway_settings Gateway tab settings
*
* @since 2.11
* @return array Gateway tab settings with the PayPal Standard settings
*/
function register_gateway_settings( $gateway_settings ) {
$paypal_settings = array(
'paypal_settings' => array(
'id' => 'paypal_settings',
'name' => '<h3>' . __( 'PayPal Settings', 'easy-digital-downloads' ) . '</h3>',
'type' => 'header',
),
'paypal_documentation' => array(
'id' => 'paypal_documentation',
'name' => __( 'Documentation', 'easy-digital-downloads' ),
'desc' => documentation_settings_field(),
'type' => 'descriptive_text'
),
'paypal_connect_button' => array(
'id' => 'paypal_connect_button',
'name' => __( 'Connection Status', 'easy-digital-downloads' ),
'desc' => connect_settings_field(),
'type' => 'descriptive_text',
'class' => 'edd-paypal-connect-row',
),
'paypal_sandbox_client_id' => array(
'id' => 'paypal_sandbox_client_id',
'name' => __( 'Test Client ID', 'easy-digital-downloads' ),
'desc' => __( 'Enter your test client ID.', 'easy-digital-downloads' ),
'type' => 'text',
'size' => 'regular',
'class' => 'edd-hidden'
),
'paypal_sandbox_client_secret' => array(
'id' => 'paypal_sandbox_client_secret',
'name' => __( 'Test Client Secret', 'easy-digital-downloads' ),
'desc' => __( 'Enter your test client secret.', 'easy-digital-downloads' ),
'type' => 'password',
'size' => 'regular',
'class' => 'edd-hidden'
),
'paypal_live_client_id' => array(
'id' => 'paypal_live_client_id',
'name' => __( 'Live Client ID', 'easy-digital-downloads' ),
'desc' => __( 'Enter your live client ID.', 'easy-digital-downloads' ),
'type' => 'text',
'size' => 'regular',
'class' => 'edd-hidden'
),
'paypal_live_client_secret' => array(
'id' => 'paypal_live_client_secret',
'name' => __( 'Live Client Secret', 'easy-digital-downloads' ),
'desc' => __( 'Enter your live client secret.', 'easy-digital-downloads' ),
'type' => 'password',
'size' => 'regular',
'class' => 'edd-hidden'
),
);
$is_connected = PayPal\has_rest_api_connection();
if ( ! $is_connected ) {
$paypal_settings['paypal_settings']['tooltip_title'] = __( 'Connect with PayPal', 'easy-digital-downloads' );
$paypal_settings['paypal_settings']['tooltip_desc'] = __( 'Connecting your store with PayPal allows Easy Digital Downloads to automatically configure your store to securely communicate PayPal.<br \><br \>You may see "Sandhills Development, LLC", mentioned during the process&mdash;that is the company behind Easy Digital Downloads.', 'easy-digital-downloads' );
}
/**
* Filters the PayPal Settings.
*
* @param array $paypal_settings
*/
$paypal_settings = apply_filters( 'edd_paypal_settings', $paypal_settings );
$gateway_settings['paypal_commerce'] = $paypal_settings;
return $gateway_settings;
}
add_filter( 'edd_settings_gateways', __NAMESPACE__ . '\register_gateway_settings', 1, 1 );
/**
* Returns the content for the documentation settings.
*
* @since 2.11
* @return string
*/
function documentation_settings_field() {
ob_start();
?>
<p>
<?php
echo wp_kses( sprintf(
__( 'To learn more about the PayPal gateway, visit <a href="%s" target="_blank">our documentation</a>.', 'easy-digital-downloads' ),
'https://docs.easydigitaldownloads.com/article/2410-paypal'
), array( 'a' => array( 'href' => true, 'target' => true ) ) )
?>
</p>
<?php
if ( ! is_ssl() ) {
?>
<div class="notice notice-warning inline">
<p>
<?php
echo wp_kses( sprintf(
__( 'PayPal requires an SSL certificate to accept payments. You can learn more about obtaining an SSL certificate in our <a href="%s" target="_blank">SSL setup article</a>.', 'easy-digital-downloads' ),
'https://docs.easydigitaldownloads.com/article/994-how-to-set-up-ssl'
), array( 'a' => array( 'href' => true, 'target' => true ) ) );
?>
</p>
</div>
<?php
}
return ob_get_clean();
}

View File

@ -0,0 +1,105 @@
<?php
/**
* PayPal Commerce "Buy Now" Buttons
*
* @package easy-digital-downloads
* @subpackage Gateways\PayPal
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
*/
namespace EDD\Gateways\PayPal;
/**
* Determines whether or not Buy Now is enabled for PayPal.
*
* @since 2.11
* @return bool
*/
function is_buy_now_enabled() {
return edd_shop_supports_buy_now() && array_key_exists( 'paypal_commerce', edd_get_enabled_payment_gateways() );
}
/**
* Sets the gateway to `paypal_commerce` when building straight to gateway data.
* This is technically already set via `edd_build_straight_to_gateway_data()` but we want
* to make 100% sure we override PayPal Standard when PayPal Commerce is enabled.
*
* @param array $purchase_data
*
* @since 2.11
* @return array
*/
function straight_to_gateway_data( $purchase_data ) {
if ( is_buy_now_enabled() ) {
$_REQUEST['edd-gateway'] = 'paypal_commerce';
$purchase_data['gateway'] = 'paypal_commerce';
}
return $purchase_data;
}
add_filter( 'edd_straight_to_gateway_purchase_data', __NAMESPACE__ . '\straight_to_gateway_data' );
/**
* Adds the `edd-paypal-checkout-buy-now` class to qualified shortcodes.
*
* @param array $args
*
* @since 2.11
* @return array
*/
function maybe_add_purchase_link_class( $args ) {
if ( ! is_buy_now_enabled() ) {
return $args;
}
// Don't add class if "Free Downloads" is active and available for this download.
if ( function_exists( 'edd_free_downloads_use_modal' ) ) {
if ( edd_free_downloads_use_modal( $args['download_id'] ) && ! edd_has_variable_prices( $args['download_id'] ) ) {
return $args;
}
}
// Don't add class if Recurring is enabled for this download.
if ( function_exists( 'edd_recurring' ) ) {
// Overall download is recurring.
if ( edd_recurring()->is_recurring( $args['download_id'] ) ) {
return $args;
}
// Price ID is recurring.
if ( ! empty( $args['price_id'] ) && edd_recurring()->is_price_recurring( $args['download_id'], $args['price_id'] ) ) {
return $args;
}
}
if ( ! empty( $args['direct'] ) ) {
$args['class'] .= ' edd-paypal-checkout-buy-now';
}
return $args;
}
add_filter( 'edd_purchase_link_args', __NAMESPACE__ . '\maybe_add_purchase_link_class' );
/**
* Registers PayPal Commerce JavaScript if using "direct" buy now links.
*
* @param int $download_id ID of the download.
* @param array $args Purchase link arguments.
*
* @since 2.11
*/
function maybe_enable_buy_now_js( $download_id, $args ) {
if ( ! empty( $args['direct'] ) && is_buy_now_enabled() ) {
register_js( true );
$timestamp = time();
?>
<input type="hidden" name="edd_process_paypal_nonce" value="<?php echo esc_attr( wp_create_nonce( 'edd_process_paypal' ) ); ?>">
<input type="hidden" name="edd-process-paypal-token" data-timestamp="<?php echo esc_attr( $timestamp ); ?>" data-token="<?php echo esc_attr( \EDD\Utils\Tokenizer::tokenize( $timestamp ) ); ?>" />
<?php
}
}
add_action( 'edd_purchase_link_end', __NAMESPACE__ . '\maybe_enable_buy_now_js', 10, 2 );

View File

@ -0,0 +1,462 @@
<?php
/**
* PayPal Commerce Checkout Actions
*
* @package easy-digital-downloads
* @subpackage Gateways\PayPal
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal;
use EDD\Gateways\PayPal\Exceptions\API_Exception;
use EDD\Gateways\PayPal\Exceptions\Authentication_Exception;
use EDD\Gateways\PayPal\Exceptions\Gateway_Exception;
/**
* Removes the credit card form for PayPal Commerce
*
* @access private
* @since 2.11
*/
add_action( 'edd_paypal_commerce_cc_form', '__return_false' );
/**
* Replaces the "Submit" button with a PayPal smart button.
*
* @param string $button
*
* @since 2.11
* @return string
*/
function override_purchase_button( $button ) {
if ( 'paypal_commerce' === edd_get_chosen_gateway() && edd_get_cart_total() ) {
ob_start();
if ( ready_to_accept_payments() ) {
wp_nonce_field( 'edd_process_paypal', 'edd_process_paypal_nonce' );
$timestamp = time();
?>
<input type="hidden" name="edd-process-paypal-token" data-timestamp="<?php echo esc_attr( $timestamp ); ?>" data-token="<?php echo esc_attr( \EDD\Utils\Tokenizer::tokenize( $timestamp ) ); ?>" />
<div id="edd-paypal-errors-wrap"></div>
<div id="edd-paypal-container"></div>
<div id="edd-paypal-spinner" style="display: none;">
<span class="edd-loading-ajax edd-loading"></span>
</div>
<?php
/**
* Triggers right below the button container.
*
* @since 2.11
*/
do_action( 'edd_paypal_after_button_container' );
} else {
$error_message = current_user_can( 'manage_options' )
? __( 'Please connect your PayPal account in the gateway settings.', 'easy-digital-downloads' )
: __( 'Unexpected authentication error. Please contact a site administrator.', 'easy-digital-downloads' );
?>
<div class="edd_errors edd-alert edd-alert-error">
<p class="edd_error">
<?php echo esc_html( $error_message ); ?>
</p>
</div>
<?php
}
return ob_get_clean();
}
return $button;
}
add_filter( 'edd_checkout_button_purchase', __NAMESPACE__ . '\override_purchase_button', 10000 );
/**
* Sends checkout error messages via AJAX.
*
* This overrides the normal error behaviour in `edd_process_purchase_form()` because we *always*
* want to send errors back via JSON.
*
* @param array $user User data.
* @param array $valid_data Validated form data.
* @param array $posted Raw $_POST data.
*
* @since 2.11
* @return void
*/
function send_ajax_errors( $user, $valid_data, $posted ) {
if ( empty( $valid_data['gateway'] ) || 'paypal_commerce' !== $valid_data['gateway'] ) {
return;
}
$errors = edd_get_errors();
if ( $errors ) {
edd_clear_errors();
wp_send_json_error( edd_build_errors_html( $errors ) );
}
}
add_action( 'edd_checkout_user_error_checks', __NAMESPACE__ . '\send_ajax_errors', 99999, 3 );
/**
* Creates a new order in PayPal and EDD.
*
* @param array $purchase_data
*
* @since 2.11
* @return void
*/
function create_order( $purchase_data ) {
edd_debug_log( 'PayPal - create_order()' );
if ( ! ready_to_accept_payments() ) {
edd_record_gateway_error(
__( 'PayPal Gateway Error', 'easy-digital-downloads' ),
__( 'Account not ready to accept payments.', 'easy-digital-downloads' )
);
$error_message = current_user_can( 'manage_options' )
? __( 'Please connect your PayPal account in the gateway settings.', 'easy-digital-downloads' )
: __( 'Unexpected authentication error. Please contact a site administrator.', 'easy-digital-downloads' );
wp_send_json_error( edd_build_errors_html( array(
'paypal-error' => $error_message
) ) );
}
try {
// Create pending payment in EDD.
$payment_args = array(
'price' => $purchase_data['price'],
'date' => $purchase_data['date'],
'user_email' => $purchase_data['user_email'],
'purchase_key' => $purchase_data['purchase_key'],
'currency' => edd_get_currency(),
'downloads' => $purchase_data['downloads'],
'cart_details' => $purchase_data['cart_details'],
'user_info' => $purchase_data['user_info'],
'status' => 'pending',
'gateway' => 'paypal_commerce'
);
$payment_id = edd_insert_payment( $payment_args );
if ( ! $payment_id ) {
throw new Gateway_Exception(
__( 'An unexpected error occurred. Please try again.', 'easy-digital-downloads' ),
500,
sprintf(
'Payment creation failed before sending buyer to PayPal. Payment data: %s',
json_encode( $payment_args )
)
);
}
$order_data = array(
'intent' => 'CAPTURE',
'purchase_units' => get_order_purchase_units( $payment_id, $purchase_data, $payment_args ),
'application_context' => array(
//'locale' => get_locale(), // PayPal doesn't like this. Might be able to replace `_` with `-`
'shipping_preference' => 'NO_SHIPPING',
'user_action' => 'PAY_NOW',
'return_url' => edd_get_checkout_uri(),
'cancel_url' => edd_get_failed_transaction_uri( '?payment-id=' . urlencode( $payment_id ) )
),
'payment_instructions' => array(
'disbursement_mode' => 'INSTANT'
)
);
// Add payer data if we have it. We won't have it when using Buy Now buttons.
if ( ! empty( $purchase_data['user_email'] ) ) {
$order_data['payer']['email_address'] = $purchase_data['user_email'];
}
if ( ! empty( $purchase_data['user_info']['first_name'] ) ) {
$order_data['payer']['name']['given_name'] = $purchase_data['user_info']['first_name'];
}
if ( ! empty( $purchase_data['user_info']['last_name'] ) ) {
$order_data['payer']['name']['surname'] = $purchase_data['user_info']['last_name'];
}
/**
* Filters the arguments sent to PayPal.
*
* @param array $order_data API request arguments.
* @param array $purchase_data Purchase data.
* @param int $payment_id ID of the EDD payment.
*
* @since 2.11
*/
$order_data = apply_filters( 'edd_paypal_order_arguments', $order_data, $purchase_data, $payment_id );
try {
$api = new API();
$response = $api->make_request( 'v2/checkout/orders', $order_data );
if ( ! isset( $response->id ) && _is_item_total_mismatch( $response ) ) {
edd_record_gateway_error(
__( 'PayPal Gateway Warning', 'easy-digital-downloads' ),
sprintf(
/* Translators: %s - Original order data sent to PayPal. */
__( 'PayPal could not complete the transaction with the itemized breakdown. Original order data sent: %s', 'easy-digital-downloads' ),
json_encode( $order_data )
),
$payment_id
);
// Try again without the item breakdown. That way if we have an error in our totals the whole API request won't fail.
$order_data['purchase_units'] = array(
get_order_purchase_units_without_breakdown( $payment_id, $purchase_data, $payment_args )
);
// Re-apply the filter.
$order_data = apply_filters( 'edd_paypal_order_arguments', $order_data, $purchase_data, $payment_id );
$response = $api->make_request( 'v2/checkout/orders', $order_data );
}
if ( ! isset( $response->id ) ) {
throw new Gateway_Exception(
__( 'An error occurred while communicating with PayPal. Please try again.', 'easy-digital-downloads' ),
$api->last_response_code,
sprintf(
'Unexpected response when creating order: %s',
json_encode( $response )
)
);
}
edd_debug_log( sprintf( '-- Successful PayPal response. PayPal order ID: %s; EDD order ID: %d', esc_html( $response->id ), $payment_id ) );
edd_update_payment_meta( $payment_id, 'paypal_order_id', sanitize_text_field( $response->id ) );
/*
* Send successfully created order ID back.
* We also send back a new nonce, for verification in the next step: `capture_order()`.
* If the user was just logged into a new account, the previously sent nonce may have
* become invalid.
*/
$timestamp = time();
wp_send_json_success( array(
'paypal_order_id' => $response->id,
'edd_order_id' => $payment_id,
'nonce' => wp_create_nonce( 'edd_process_paypal' ),
'timestamp' => $timestamp,
'token' => \EDD\Utils\Tokenizer::tokenize( $timestamp ),
) );
} catch ( Authentication_Exception $e ) {
throw new Gateway_Exception( __( 'An authentication error occurred. Please try again.', 'easy-digital-downloads' ), $e->getCode(), $e->getMessage() );
} catch ( API_Exception $e ) {
throw new Gateway_Exception( __( 'An error occurred while communicating with PayPal. Please try again.', 'easy-digital-downloads' ), $e->getCode(), $e->getMessage() );
}
} catch ( Gateway_Exception $e ) {
if ( ! isset( $payment_id ) ) {
$payment_id = 0;
}
$e->record_gateway_error( $payment_id );
wp_send_json_error( edd_build_errors_html( array(
'paypal-error' => $e->getMessage()
) ) );
}
}
add_action( 'edd_gateway_paypal_commerce', __NAMESPACE__ . '\create_order', 9 );
/**
* Captures the order in PayPal
*
* @since 2.11
*/
function capture_order() {
edd_debug_log( 'PayPal - capture_order()' );
try {
$token = isset( $_POST['token'] ) ? sanitize_text_field( $_POST['token'] ) : '';
$timestamp = isset( $_POST['timestamp'] ) ? sanitize_text_field( $_POST['timestamp'] ) : '';
if ( ! empty( $timestamp ) && ! empty( $token ) ) {
if ( !\EDD\Utils\Tokenizer::is_token_valid( $token, $timestamp ) ) {
throw new Gateway_Exception(
__('A validation error occurred. Please try again.', 'easy-digital-downloads'),
403,
'Token validation failed.'
);
}
} elseif ( empty( $token ) && ! empty( $_POST['edd_process_paypal_nonce'] ) ) {
if ( ! wp_verify_nonce( $_POST['edd_process_paypal_nonce'], 'edd_process_paypal' ) ) {
throw new Gateway_Exception(
__( 'A validation error occurred. Please try again.', 'easy-digital-downloads' ),
403,
'Nonce validation failed.'
);
}
} else {
throw new Gateway_Exception(
__( 'A validation error occurred. Please try again.', 'easy-digital-downloads' ),
400,
'Missing validation fields.'
);
}
if ( empty( $_POST['paypal_order_id'] ) ) {
throw new Gateway_Exception(
__( 'An unexpected error occurred. Please try again.', 'easy-digital-downloads' ),
400,
'Missing PayPal order ID during capture.'
);
}
try {
$api = new API();
$response = $api->make_request( 'v2/checkout/orders/' . urlencode( $_POST['paypal_order_id'] ) . '/capture' );
edd_debug_log( sprintf( '-- PayPal Response code: %d; order ID: %s', $api->last_response_code, esc_html( $_POST['paypal_order_id'] ) ) );
if ( ! in_array( $api->last_response_code, array( 200, 201 ) ) ) {
$message = ! empty( $response->message ) ? $response->message : __( 'Failed to process payment. Please try again.', 'easy-digital-downloads' );
/*
* If capture failed due to funding source, we want to send a `restart` back to PayPal.
* @link https://developer.paypal.com/docs/checkout/integration-features/funding-failure/
*/
if ( ! empty( $response->details ) && is_array( $response->details ) ) {
foreach ( $response->details as $detail ) {
if ( isset( $detail->issue ) && 'INSTRUMENT_DECLINED' === $detail->issue ) {
$message = __( 'Unable to complete your order with your chosen payment method. Please choose a new funding source.', 'easy-digital-downloads' );
$retry = true;
break;
}
}
}
throw new Gateway_Exception(
$message,
400,
sprintf( 'Order capture failure. PayPal response: %s', json_encode( $response ) )
);
}
$payment = $transaction_id = false;
if ( isset( $response->purchase_units ) && is_array( $response->purchase_units ) ) {
foreach ( $response->purchase_units as $purchase_unit ) {
if ( ! empty( $purchase_unit->reference_id ) ) {
$payment = edd_get_payment_by( 'key', $purchase_unit->reference_id );
$transaction_id = isset( $purchase_unit->payments->captures[0]->id ) ? $purchase_unit->payments->captures[0]->id : false;
if ( ! empty( $payment ) && isset( $purchase_unit->payments->captures[0]->status ) ) {
if ( 'COMPLETED' === strtoupper( $purchase_unit->payments->captures[0]->status ) ) {
$payment->status = 'complete';
} elseif( 'DECLINED' === strtoupper( $purchase_unit->payments->captures[0]->status ) ) {
$payment->status = 'failed';
}
}
break;
}
}
}
if ( ! empty( $payment ) ) {
/**
* Buy Now Button
*
* Fill in missing data when using "Buy Now". This bypasses checkout so not all information
* was collected prior to payment. Instead, we pull it from the PayPal info.
*/
if ( empty( $payment->email ) ) {
if ( ! empty( $response->payer->email_address ) ) {
$payment->email = sanitize_text_field( $response->payer->email_address );
}
if ( empty( $payment->first_name ) && ! empty( $response->payer->name->given_name ) ) {
$payment->first_name = sanitize_text_field( $response->payer->name->given_name );
}
if ( empty( $payment->last_name ) && ! empty( $response->payer->name->surname ) ) {
$payment->last_name = sanitize_text_field( $response->payer->name->surname );
}
if ( empty( $payment->customer_id ) && ! empty( $payment->email ) ) {
$customer = new \EDD_Customer( $payment->email );
if ( $customer->id < 1 ) {
$customer->create( array(
'email' => $payment->email,
'name' => trim( sprintf( '%s %s', $payment->first_name, $payment->last_name ) ),
'user_id' => $payment->user_id
) );
}
}
}
if ( ! empty( $transaction_id ) ) {
$payment->transaction_id = sanitize_text_field( $transaction_id );
edd_insert_payment_note( $payment->ID, sprintf(
/* Translators: %s - transaction ID */
__( 'PayPal Transaction ID: %s', 'easy-digital-downloads' ),
esc_html( $transaction_id )
) );
}
$payment->save();
if ( 'failed' === $payment->status ) {
$retry = true;
throw new Gateway_Exception(
__( 'Your payment was declined. Please try a new payment method.', 'easy-digital-downloads' ),
400,
sprintf( 'Order capture failure. PayPal response: %s', json_encode( $response ) )
);
}
}
wp_send_json_success( array( 'redirect_url' => edd_get_success_page_uri() ) );
} catch ( Authentication_Exception $e ) {
throw new Gateway_Exception( __( 'An authentication error occurred. Please try again.', 'easy-digital-downloads' ), $e->getCode(), $e->getMessage() );
} catch ( API_Exception $e ) {
throw new Gateway_Exception( __( 'An error occurred while communicating with PayPal. Please try again.', 'easy-digital-downloads' ), $e->getCode(), $e->getMessage() );
}
} catch ( Gateway_Exception $e ) {
if ( ! isset( $payment_id ) ) {
$payment_id = 0;
}
$e->record_gateway_error( $payment_id );
wp_send_json_error( array(
'message' => edd_build_errors_html( array(
'paypal_capture_failure' => $e->getMessage()
) ),
'retry' => isset( $retry ) ? $retry : false
) );
}
}
add_action( 'wp_ajax_nopriv_edd_capture_paypal_order', __NAMESPACE__ . '\capture_order' );
add_action( 'wp_ajax_edd_capture_paypal_order', __NAMESPACE__ . '\capture_order' );
/**
* Gets a fresh set of gateway options when a PayPal order is cancelled.
* @link https://github.com/awesomemotive/easy-digital-downloads/issues/8883
*
* @since 2.11.3
* @return void
*/
function cancel_order() {
$nonces = array();
$gateways = edd_get_enabled_payment_gateways( true );
foreach ( $gateways as $gateway_id => $gateway ) {
$nonces[ $gateway_id ] = wp_create_nonce( 'edd-gateway-selected-' . esc_attr( $gateway_id ) );
}
wp_send_json_success(
array(
'nonces' => $nonces,
)
);
}
add_action( 'wp_ajax_nopriv_edd_cancel_paypal_order', __NAMESPACE__ . '\cancel_order' );
add_action( 'wp_ajax_edd_cancel_paypal_order', __NAMESPACE__ . '\cancel_order' );

View File

@ -0,0 +1,178 @@
<?php
/**
* Account Status Validator
*
* This validator helps to check the status of a PayPal merchant account
* and ensure it's ready to receive payments. This is used to display
* the connection status on the admin settings page, and also to help
* determine if we should allow the merchant to start processing payments.
*
* @package easy-digital-downloads
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal;
use EDD\Gateways\PayPal;
class AccountStatusValidator {
/**
* @var bool Whether or not we have REST API credentials.
*/
public $has_rest_credentials = false;
/**
* @var \WP_Error Errors thrown when checking REST API credentials.
*/
public $errors_for_credentials;
/**
* @var array|false Merchant details, if any.
*/
public $merchant_details = false;
/**
* @var \WP_Error Errors thrown when checking merchant account status.
*/
public $errors_for_merchant_account;
/**
* @var null|object Webhook object from the API.
*/
public $webhook;
/**
* @var array Enabled webhook event names.
*/
public $enabled_webhook_events = array();
/**
* @var \WP_Error Errors thrown when checking webhook status.
*/
public $errors_for_webhook;
/**
* @var string Identifier of the connected account -- if available.
*/
public $connected_account = '';
/**
* @var string Current store mode.
*/
private $mode;
/**
* AccountStatusValidator constructor.
*
* @param string $mode Mode to check (`live` or `sandbox`). If omitted, current store mode is used.
*/
public function __construct( $mode = '' ) {
if ( empty( $mode ) ) {
$mode = edd_is_test_mode() ? PayPal\API::MODE_SANDBOX : PayPal\API::MODE_LIVE;
}
$this->mode = $mode;
// Set up base error objects.
$this->errors_for_credentials = new \WP_Error();
$this->errors_for_merchant_account = new \WP_Error();
$this->errors_for_webhook = new \WP_Error();
}
/**
* Checks everything.
*
* @since 2.11
*/
public function check() {
$this->check_rest();
$this->check_merchant_account();
$this->check_webhook();
}
/**
* Checks for valid REST API credentials.
*
* @since 2.11
*/
public function check_rest() {
$credentials = array(
'client_id' => edd_get_option( 'paypal_' . $this->mode . '_client_id' ),
'client_secret' => edd_get_option( 'paypal_' . $this->mode . '_client_secret' ),
);
foreach ( $credentials as $credential ) {
if ( empty( $credential ) ) {
$this->errors_for_credentials->add( 'no_credentials', __( 'Not connected.', 'easy-digital-downloads' ) );
break;
}
}
}
/**
* Determines if the merchant account is ready to accept payments.
* It's possible (I think) to have valid API credentials ( @see AccountStatusValidator::check_rest() )
* but still be unable to start taking payments, such as because your account
* email hasn't been confirmed yet.
*
* @since 2.11
*/
public function check_merchant_account() {
try {
$this->merchant_details = MerchantAccount::retrieve();
$this->merchant_details->validate();
if ( ! $this->merchant_details->is_account_ready() ) {
foreach ( $this->merchant_details->get_errors()->get_error_codes() as $code ) {
$this->errors_for_merchant_account->add( $code, $this->merchant_details->get_errors()->get_error_message( $code ) );
}
}
} catch ( Exceptions\MissingMerchantDetails $e ) {
$this->errors_for_merchant_account->add( 'missing_merchant_details', __( 'Missing merchant details from PayPal. Please reconnect and make sure you click the button to be redirected back to your site.', 'easy-digital-downloads' ) );
} catch ( Exceptions\InvalidMerchantDetails $e ) {
$this->errors_for_merchant_account->add( 'invalid_merchant_details', $e->getMessage() );
}
}
/**
* Confirms that the webhook is set up and has all the necessary events registered.
*
* @since 2.11
*/
public function check_webhook() {
try {
$this->webhook = Webhooks\get_webhook_details( $this->mode );
if ( empty( $this->webhook->id ) ) {
throw new \Exception( __( 'Webhook not configured. Some actions may not work properly.', 'easy-digital-downloads' ) );
}
// Now compare the events to make sure we have them all.
$expected_events = array_keys( Webhooks\get_webhook_events( $this->mode ) );
if ( ! empty( $this->webhook->event_types ) && is_array( $this->webhook->event_types ) ) {
foreach ( $this->webhook->event_types as $event_type ) {
if ( ! empty( $event_type->name ) && ! empty( $event_type->status ) && 'ENABLED' === strtoupper( $event_type->status ) ) {
$this->enabled_webhook_events[] = $event_type->name;
}
}
}
$missing_events = array_diff( $expected_events, $this->enabled_webhook_events );
$number_missing = count( $missing_events );
if ( $number_missing ) {
$this->errors_for_webhook->add( 'missing_events', _n(
'Webhook is configured but missing an event. Click "Sync Webhook" to correct this.',
'Webhook is configured but missing events. Click "Sync Webhook" to correct this.',
$number_missing,
'easy-digital-downloads'
) );
}
} catch ( \Exception $e ) {
$this->errors_for_webhook->add( 'webhook_missing', $e->getMessage() );
}
}
}

View File

@ -0,0 +1,193 @@
<?php
/**
* PayPal Merchant Account Details
*
* Contains information about the connected PayPal account.
*
* @package easy-digital-downloads
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal;
use EDD\Gateways\PayPal\Exceptions\InvalidMerchantDetails;
use EDD\Gateways\PayPal\Exceptions\MissingMerchantDetails;
class MerchantAccount {
/**
* @var string Merchant ID of the seller's PayPal account.
*/
public $merchant_id;
/**
* @var bool Indicates whether the seller account can receive payments.
*/
public $payments_receivable;
/**
* @var bool Indicates whether the primary email of the seller has been confirmed.
*/
public $primary_email_confirmed;
/**
* @var array An array of all products that are integrated with the partner for the seller.
*/
public $products;
/**
* @var \WP_Error
*/
private $wp_error;
/**
* MerchantAccount constructor.
*
* @param array $details
*/
public function __construct( $details ) {
foreach ( $details as $key => $value ) {
$this->{$key} = $value;
}
$this->wp_error = new \WP_Error();
}
/**
* Builds a new MerchantAccount object from a JSON object.
*
* @since 2.11
*
* @param string $json
*
* @return MerchantAccount
*/
public static function from_json( $json ) {
$merchant_details = json_decode( $json, true );
if ( empty( $merchant_details ) || ! is_array( $merchant_details ) ) {
$merchant_details = array();
}
return new MerchantAccount( $merchant_details );
}
/**
* Converts the account details to JSON.
*
* @since 2.11
*
* @return string|false
*/
public function to_json() {
return json_encode( get_object_vars( $this ) );
}
/**
* Determines whether or not the details are valid.
* Note: This does NOT determine actual "ready to accept payments" status, it just
* verifies that we have all the information we need to determine that.
*
* @throws MissingMerchantDetails
* @throws InvalidMerchantDetails
*/
public function validate() {
if ( empty( $this->merchant_id ) ) {
throw new MissingMerchantDetails();
}
$required_properties = array(
'merchant_id',
'payments_receivable',
'primary_email_confirmed',
'products',
);
$valid_properties = array();
foreach( $required_properties as $property ) {
if ( property_exists( $this, $property ) && ! is_null( $this->{$property} ) ) {
$valid_properties[] = $property;
}
}
$difference = array_diff( $required_properties, $valid_properties );
if ( $difference ) {
throw new InvalidMerchantDetails(
'Please click "Re-Check Payment Status" below to confirm your payment status.'
);
}
}
/**
* Determines whether or not the account is ready to accept payments.
*
* @since 2.11
*
* @return bool
*/
public function is_account_ready() {
if ( ! $this->payments_receivable ) {
$this->wp_error->add( 'payments_receivable', __( 'Your account is unable to receive payments. Please contact PayPal customer support.', 'easy-digital-downloads' ) );
}
if ( ! $this->primary_email_confirmed ) {
$this->wp_error->add(
'primary_email_confirmed',
__( 'Your PayPal email address needs to be confirmed.', 'easy-digital-downloads' )
);
}
return empty( $this->wp_error->errors );
}
/**
* Retrieves errors preventing the account from being "ready".
*
* @see MerchantAccount::is_account_ready()
*
* @since 2.11
*
* @return \WP_Error
*/
public function get_errors() {
return $this->wp_error;
}
/**
* Returns the option name for the current mode.
*
* @since 2.11
*
* @return string
*/
private static function get_option_name() {
return sprintf(
'edd_paypal_%s_merchant_details',
edd_is_test_mode() ? API::MODE_SANDBOX : API::MODE_LIVE
);
}
/**
* Saves the merchant details.
*
* @since 2.11
*/
public function save() {
update_option( self::get_option_name(), $this->to_json() );
}
/**
* Retrieves the saved merchant details.
*
* @since 2.11
*
* @return MerchantAccount
* @throws \InvalidArgumentException
*/
public static function retrieve() {
return self::from_json( get_option( self::get_option_name(), '' ) );
}
}

View File

@ -0,0 +1,270 @@
<?php
/**
* PayPal REST API Wrapper
*
* @package easy-digital-downloads
* @subpackage Gateways\PayPal
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal;
use EDD\Gateways\PayPal\Exceptions\API_Exception;
use EDD\Gateways\PayPal\Exceptions\Authentication_Exception;
/**
* Class API
*
* @property string $mode
* @property string $api_url
* @property string $client_id
* @property string $client_secret
* @property string $token_cache_key
* @property int $last_response_code
*
* @package EDD\PayPal
*/
class API {
const MODE_SANDBOX = 'sandbox';
const MODE_LIVE = 'live';
/**
* Mode to use for API requests
*
* @var string
*/
private $mode;
/**
* Base API URL
*
* @var string
*/
private $api_url;
/**
* Client ID
*
* @var string
*/
private $client_id;
/**
* Client secret
*
* @var string
*/
private $client_secret;
/**
* Cache key to use for the token.
*
* @var string
*/
private $token_cache_key;
/**
* Response code from the last API request.
*
* @var int
*/
private $last_response_code;
/**
* API constructor.
*
* @param string $mode Mode to connect in. Either `sandbox` or `live`.
* @param array $credentials Optional. Credentials to use for the connection. If omitted, saved store
* credentials are used.
*
* @throws Authentication_Exception
*/
public function __construct( $mode = '', $credentials = array() ) {
// If mode is not provided, use the current store mode.
if ( empty( $mode ) ) {
$mode = edd_is_test_mode() ? self::MODE_SANDBOX : self::MODE_LIVE;
}
$this->mode = $mode;
if ( self::MODE_SANDBOX === $mode ) {
$this->api_url = 'https://api-m.sandbox.paypal.com';
} else {
$this->api_url = 'https://api-m.paypal.com';
}
if ( empty( $credentials ) ) {
$credentials = array(
'client_id' => edd_get_option( 'paypal_' . $this->mode . '_client_id' ),
'client_secret' => edd_get_option( 'paypal_' . $this->mode . '_client_secret' ),
);
}
$this->set_credentials( $credentials );
}
/**
* Magic getter
*
* @param string $property
*
* @since 2.10
* @return mixed
*/
public function __get( $property ) {
return isset( $this->{$property} ) ? $this->{$property} : null;
}
/**
* Sets the credentials to use for API requests.
*
* @param array $creds {
* Credentials to set.
*
* @type string $client_id PayPal client ID.
* @type string $client_secret PayPal client secret.
* @type string $cache_key Cache key used for storing the access token until it expires. Should be unique to
* the set of credentials. The mode is automatically appended, so should not be
* included manually.
* }
*
* @since 2.11
* @throws Authentication_Exception
*/
public function set_credentials( $creds ) {
$creds = wp_parse_args( $creds, array(
'client_id' => '',
'client_secret' => '',
'cache_key' => 'edd_paypal_commerce_access_token'
) );
$required_creds = array( 'client_id', 'client_secret', 'cache_key' );
foreach ( $required_creds as $cred_id ) {
if ( empty( $creds[ $cred_id ] ) ) {
throw new Authentication_Exception( sprintf(
/* Translators: %s - The ID of the PayPal credential */
__( 'Missing PayPal credential: %s', 'easy-digital-downloads' ),
$cred_id
) );
}
}
foreach ( $creds as $cred_id => $cred_value ) {
$this->{$cred_id} = $cred_value;
}
$this->token_cache_key = sanitize_key( $creds['cache_key'] . '_' . $this->mode );
}
/**
* Retrieves the access token. This checks cache first, and if the cached token isn't valid then
* a new one is generated from the API.
*
* @since 2.11
* @return Token
* @throws API_Exception
*/
public function get_access_token() {
try {
$token = Token::from_json( (string) get_option( $this->token_cache_key ) );
return ! $token->is_expired() ? $token : $this->generate_access_token();
} catch ( \RuntimeException $e ) {
return $this->generate_access_token();
}
}
/**
* Generates a new access token and caches it.
*
* @since 2.11
* @return Token
* @throws API_Exception
*/
private function generate_access_token() {
$response = wp_remote_post( $this->api_url . '/v1/oauth2/token', array(
'headers' => array(
'Content-Type' => 'application/x-www-form-urlencoded',
'Authorization' => sprintf( 'Basic %s', base64_encode( sprintf( '%s:%s', $this->client_id, $this->client_secret ) ) ),
'timeout' => 15
),
'body' => array(
'grant_type' => 'client_credentials'
)
) );
$body = json_decode( wp_remote_retrieve_body( $response ) );
$code = intval( wp_remote_retrieve_response_code( $response ) );
if ( is_wp_error( $response ) ) {
throw new API_Exception( $response->get_error_message(), $code );
}
if ( ! empty( $body->error_description ) ) {
throw new API_Exception( $body->error_description, $code );
}
if ( 200 !== $code ) {
throw new API_Exception( sprintf(
/* Translators: %d - HTTP response code. */
__( 'Unexpected response code: %d', 'easy-digital-downloads' ),
$code
), $code );
}
$token = new Token( $body );
update_option( $this->token_cache_key, $token->to_json() );
return $token;
}
/**
* Makes an API request.
*
* @param string $endpoint API endpoint.
* @param array $body Array of data to send in the request.
* @param array $headers Array of headers.
* @param string $method HTTP method.
*
* @since 2.11
* @return mixed
* @throws API_Exception
*/
public function make_request( $endpoint, $body = array(), $headers = array(), $method = 'POST' ) {
$headers = wp_parse_args( $headers, array(
'Content-Type' => 'application/json',
'Authorization' => sprintf( 'Bearer %s', $this->get_access_token()->token() ),
'PayPal-Partner-Attribution-Id' => EDD_PAYPAL_PARTNER_ATTRIBUTION_ID
) );
$request_args = array(
'method' => $method,
'timeout' => 15,
'headers' => $headers,
'user-agent' => 'Easy Digital Downloads/' . EDD_VERSION . '; ' . get_bloginfo( 'name' ),
);
if ( ! empty( $body ) ) {
$request_args['body'] = json_encode( $body );
}
// In a few rare cases, we may be providing a full URL to `$endpoint` instead of just the path.
$api_url = ( 'https://' === substr( $endpoint, 0, 8 ) ) ? $endpoint : $this->api_url . '/' . $endpoint;
$response = wp_remote_request( $api_url, $request_args );
if ( is_wp_error( $response ) ) {
throw new API_Exception( $response->get_error_message() );
}
$this->last_response_code = intval( wp_remote_retrieve_response_code( $response ) );
return json_decode( wp_remote_retrieve_body( $response ) );
}
}

View File

@ -0,0 +1,112 @@
<?php
/**
* PayPal REST API Token
*
* @package easy-digital-downloads
* @subpackage Gateways\PayPal
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
*/
namespace EDD\Gateways\PayPal;
class Token {
/**
* Token object
*
* @var object
*/
private $token_object;
/**
* Token constructor.
*
* @param $token_object
*
* @throws \RuntimeException
*/
public function __construct( $token_object ) {
if ( is_object( $token_object ) && ! isset( $token_object->created ) ) {
$token_object->created = time();
}
if ( ! $this->is_valid( $token_object ) ) {
throw new \RuntimeException( __( 'Invalid token.', 'easy-digital-downloads' ) );
}
$this->token_object = $token_object;
}
/**
* Creates a new token from a JSON string.
*
* @param string $json
*
* @return Token
* @throws \Exception
*/
public static function from_json( $json ) {
return new Token( json_decode( $json ) );
}
/**
* Returns the token object as a JSON string.
*
* @since 2.11
* @return string|false
*/
public function to_json() {
return json_encode( $this->token_object );
}
/**
* Determines whether or not the token has expired.
*
* @since 2.11
* @return bool
*/
public function is_expired() {
// Regenerate tokens 10 minutes early, just in case.
$expires_in = $this->token_object->expires_in - ( 10 * MINUTE_IN_SECONDS );
return time() > $this->token_object->created + $expires_in;
}
/**
* Returns the access token.
*
* @since 2.11
* @return string
*/
public function token() {
return $this->token_object->access_token;
}
/**
* Determines whether or not we have a valid token object.
* Note: This does not check the _expiration_ of the token, just validates that the expected
* data is _present_.
*
* @param object $token_object
*
* @since 2.11
* @return bool
*/
private function is_valid( $token_object ) {
$required_properties = array(
'created',
'access_token',
'expires_in'
);
foreach ( $required_properties as $property ) {
if ( ! isset( $token_object->{$property} ) ) {
return false;
}
}
return true;
}
}

View File

@ -0,0 +1,102 @@
<?php
/**
* Deprecated PayPal Functions
*
* @package easy-digital-downloads
* @subpackage Gateways\PayPal
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 3.0
*/
namespace EDD\Gateways\PayPal;
/**
* Adds a "Refund in PayPal" checkbox when switching the payment's status to "Refunded".
*
* @deprecated 3.0 In favor of a `edd_after_submit_refund_table` hook.
*
* @param int $payment_id
*
* @since 2.11
* @return void
*/
function add_refund_javascript( $payment_id ) {
_edd_deprecated_function( __FUNCTION__, '3.0', null, debug_backtrace() );
$payment = edd_get_payment( $payment_id );
if ( ! $payment || 'paypal_commerce' !== $payment->gateway ) {
return;
}
$mode = ( 'live' === $payment->mode ) ? API::MODE_LIVE : API::MODE_SANDBOX;
try {
$api = new API( $mode );
} catch ( Exceptions\Authentication_Exception $e ) {
// If we don't have credentials.
return;
}
$label = __( 'Refund Transaction in PayPal', 'easy-digital-downloads' );
?>
<script type="text/javascript">
jQuery( document ).ready( function ( $ ) {
$( 'select[name=edd-payment-status]' ).change( function () {
if ( 'refunded' === $( this ).val() ) {
$( this ).parent().parent().append( '<input type="checkbox" id="edd-paypal-commerce-refund" name="edd-paypal-commerce-refund" value="1" style="margin-top:0">' );
$( this ).parent().parent().append( '<label for="edd-paypal-commerce-refund"><?php echo esc_html( $label ); ?></label>' );
} else {
$( '#edd-paypal-commerce-refund' ).remove();
$( 'label[for="edd-paypal-commerce-refund"]' ).remove();
}
} );
} );
</script>
<?php
}
/**
* Refunds the transaction in PayPal, if the option was selected.
*
* @deprecated 3.0 In favor of `edd_refund_order` hook.
*
* @param \EDD_Payment $payment The payment being refunded.
*
* @since 2.11
* @return void
*/
function maybe_refund_transaction( \EDD_Payment $payment ) {
_edd_deprecated_function( __FUNCTION__, '3.0', null, debug_backtrace() );
if ( ! current_user_can( 'edit_shop_payments', $payment->ID ) ) {
return;
}
if ( 'paypal_commerce' !== $payment->gateway || empty( $_POST['edd-paypal-commerce-refund'] ) ) {
return;
}
// Payment status should be coming from "publish" or "revoked".
// @todo In 3.0 use `edd_get_refundable_order_statuses()`
if ( ! in_array( $payment->old_status, array( 'publish', 'complete', 'revoked', 'edd_subscription' ) ) ) {
return;
}
// If the payment has already been refunded, bail.
if ( $payment->get_meta( '_edd_paypal_refunded', true ) ) {
return;
}
// Process the refund.
try {
refund_transaction( $payment );
} catch ( \Exception $e ) {
edd_insert_payment_note( $payment->ID, sprintf(
/* Translators: %s - The error message */
__( 'Failed to refund transaction in PayPal. Error Message: %s', 'easy-digital-downloads' ),
$e->getMessage()
) );
}
}

View File

@ -0,0 +1,17 @@
<?php
/**
* API Exception
*
* Thrown when an API request has failed.
*
* @package easy-digital-downloads
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal\Exceptions;
class API_Exception extends \Exception {
}

View File

@ -0,0 +1,17 @@
<?php
/**
* Authentication Exception
*
* Will be thrown if PayPal API credentials are missing or invalid.
*
* @package easy-digital-downloads
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal\Exceptions;
class Authentication_Exception extends \Exception {
}

View File

@ -0,0 +1,59 @@
<?php
/**
* PayPal Gateway Exception
*
* @package easy-digital-downloads
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal\Exceptions;
class Gateway_Exception extends \Exception {
/**
* More specific message, used for recording gateway errors.
*
* @var string
*/
private $debug_message;
/**
* Gateway_Exception constructor.
*
* @param string $message Exception message. This might be vague, as it's usually presented to the end user.
* @param int $code Error code.
* @param string $debug_message More detailed debug message, used when recording gateway errors.
*
* @since 2.11
*/
public function __construct( $message = '', $code = 0, $debug_message = '' ) {
$this->debug_message = $debug_message;
parent::__construct( $message, $code );
}
/**
* Records a gateway error based off this exception.
*
* @param int $payment_id
*
* @since 2.11
*/
public function record_gateway_error( $payment_id = 0 ) {
$message = ! empty( $this->debug_message ) ? $this->debug_message : $this->getMessage();
edd_record_gateway_error(
__( 'PayPal Gateway Error', 'easy-digital-downloads' ),
sprintf(
/* Translators: %d - HTTP response code; %s - Error message */
__( 'Response Code: %d; Message: %s', 'easy-digital-downloads' ),
$this->getCode(),
$message
),
$payment_id
);
}
}

View File

@ -0,0 +1,15 @@
<?php
/**
* Thrown if merchant details are supplied, but invalid.
*
* @package easy-digital-downloads
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal\Exceptions;
class InvalidMerchantDetails extends \InvalidArgumentException {
}

View File

@ -0,0 +1,17 @@
<?php
/**
* Thrown if there are no merchant details at all
*
* @see \EDD\Gateways\PayPal\MerchantAccount
*
* @package easy-digital-downloads
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal\Exceptions;
class MissingMerchantDetails extends \Exception {
}

View File

@ -0,0 +1,329 @@
<?php
/**
* PayPal Commerce Functions
*
* @package easy-digital-downloads
* @subpackage Gateways\PayPal
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal;
use EDD\Gateways\PayPal\Exceptions\Authentication_Exception;
/**
* Determines whether or not there's a valid REST API connection.
*
* @param string $mode Mode to check (`live` or `sandbox`).
*
* @since 2.11
* @return bool
*/
function has_rest_api_connection( $mode = '' ) {
try {
$api = new API( $mode );
return true;
} catch ( Authentication_Exception $e ) {
return false;
}
}
/**
* Determines whether or not the account is ready to accept payments.
* Requirements:
*
* - API keys must be set.
* - Merchant account must be ready to accept payments.
*
* @see API::set_credentials()
* @see AccountStatusValidator::check_merchant_account()
*
* @since 2.11
*
* @param string $mode
*
* @return bool
*/
function ready_to_accept_payments( $mode = '' ) {
if ( empty( $mode ) ) {
$mode = edd_is_test_mode() ? API::MODE_SANDBOX : API::MODE_LIVE;
}
if ( ! has_rest_api_connection( $mode ) ) {
return false;
}
$validator = new AccountStatusValidator( $mode );
$validator->check_merchant_account();
return empty( $validator->errors_for_merchant_account->errors );
}
/**
* Determines whether or not PayPal Standard should be enabled.
* This returns true if the store owner previously had a PayPal Standard connection but has not yet
* connected to the new REST API implementation.
*
* If PayPal Standard is enabled, then PayPal payments run through the legacy API.
*
* @param string $mode If omitted, current site mode is used.
*
* @since 2.11
* @return bool
*/
function paypal_standard_enabled( $mode = '' ) {
if ( empty( $mode ) ) {
$mode = edd_is_test_mode() ? API::MODE_SANDBOX : API::MODE_LIVE;
}
$rest_connection = has_rest_api_connection( $mode );
$enabled = ! $rest_connection && edd_get_option( 'paypal_email' );
/**
* Filters whether or not PayPal Standard is enabled.
*
* @since 2.11
*
* @param bool $enabled
*/
return apply_filters( 'edd_paypal_standard_enabled', $enabled );
}
/**
* Returns the partner merchant ID for a given mode.
*
* @param string $mode If omitted, current site mode is used.
*
* @since 2.11
* @return string
*/
function get_partner_merchant_id( $mode = '' ) {
if ( empty( $mode ) ) {
$mode = edd_is_test_mode() ? API::MODE_SANDBOX : API::MODE_LIVE;
}
if ( API::MODE_LIVE === $mode ) {
return EDD_PAYPAL_MERCHANT_ID;
} else {
return EDD_PAYPAL_SANDBOX_MERCHANT_ID;
}
}
/**
* Returns the styles used for the PayPal buttons.
*
* @return array
*/
function get_button_styles() {
$styles = array(
'layout' => 'vertical',
'size' => 'responsive',
'shape' => 'rect',
'color' => 'gold',
'label' => 'paypal'
);
if ( ! edd_is_checkout() ) {
$styles['layout'] = 'horizontal';
$styles['label'] = 'buynow';
}
/**
* Filters the button styles.
*
* @since 2.11
*/
return apply_filters( 'edd_paypal_smart_button_style', $styles );
}
/**
* Gets the PayPal purchase units without the individual item breakdown.
*
* @since 2.11.2
*
* @param int $payment_id The payment/order ID.
* @param array $purchase_data The array of purchase data.
* @param array $payment_args The array created to insert the payment into the database.
*
* @return array
*/
function get_order_purchase_units_without_breakdown( $payment_id, $purchase_data, $payment_args ) {
$order_amount = array(
'currency_code' => edd_get_currency(),
'value' => (string) edd_sanitize_amount( $purchase_data['price'] ),
);
if ( (float) $purchase_data['tax'] > 0 ) {
$order_amount['breakdown'] = array(
'item_total' => array(
'currency_code' => edd_get_currency(),
'value' => (string) edd_sanitize_amount( $purchase_data['price'] - $purchase_data['tax'] )
),
'tax_total' => array(
'currency_code' => edd_get_currency(),
'value' => (string) edd_sanitize_amount( $purchase_data['tax'] ),
)
);
}
return array(
'reference_id' => $payment_args['purchase_key'],
'amount' => $order_amount,
'custom_id' => $payment_id
);
}
/**
* Gets the PayPal purchase units. The order breakdown includes the order items, tax, and discount.
*
* @since 2.11.2
* @param int $payment_id The payment/order ID.
* @param array $purchase_data The array of purchase data.
* @param array $payment_args The array created to insert the payment into the database.
* @return array
*/
function get_order_purchase_units( $payment_id, $purchase_data, $payment_args ) {
$currency = edd_get_currency();
$order_subtotal = $purchase_data['subtotal'];
$items = get_order_items( $purchase_data );
// Adjust the order subtotal if any items are discounted.
foreach ( $items as &$item ) {
// A discount can be negative, so cast it to an absolute value for comparison.
if ( (float) abs( $item['discount'] ) > 0 ) {
$order_subtotal -= $item['discount'];
}
// The discount amount is not passed to PayPal as part of the $item.
unset( $item['discount'] );
}
$discount = 0;
// Fees which are not item specific need to be added to the PayPal data as order items.
if ( ! empty( $purchase_data['fees'] ) ) {
foreach ( $purchase_data['fees'] as $fee ) {
if ( ! empty( $fee['download_id'] ) ) {
continue;
}
// Positive fees.
if ( floatval( $fee['amount'] ) > 0 ) {
$items[] = array(
'name' => stripslashes_deep( html_entity_decode( wp_strip_all_tags( $fee['label'] ), ENT_COMPAT, 'UTF-8' ) ),
'unit_amount' => array(
'currency_code' => $currency,
'value' => (string) edd_sanitize_amount( $fee['amount'] ),
),
'quantity' => 1,
);
$order_subtotal += abs( $fee['amount'] );
} else {
// This is a negative fee (discount) not assigned to a specific Download
$discount += abs( $fee['amount'] );
}
}
}
$order_amount = array(
'currency_code' => $currency,
'value' => (string) edd_sanitize_amount( $purchase_data['price'] ),
'breakdown' => array(
'item_total' => array(
'currency_code' => $currency,
'value' => (string) edd_sanitize_amount( $order_subtotal ),
),
),
);
$tax = (float) $purchase_data['tax'] > 0 ? $purchase_data['tax'] : 0;
if ( $tax > 0 ) {
$order_amount['breakdown']['tax_total'] = array(
'currency_code' => $currency,
'value' => (string) edd_sanitize_amount( $tax ),
);
}
// This is only added by negative global fees.
if ( $discount > 0 ) {
$order_amount['breakdown']['discount'] = array(
'currency_code' => $currency,
'value' => (string) edd_sanitize_amount( $discount ),
);
}
return array(
wp_parse_args( array(
'amount' => $order_amount,
'items' => $items
), get_order_purchase_units_without_breakdown( $payment_id, $purchase_data, $payment_args ) )
);
}
/**
* Gets an array of order items, formatted for PayPal, from the $purchase_data.
*
* @since 2.11.2
* @param array $purchase_data
* @return array
*/
function get_order_items( $purchase_data ) {
// Create an array of items for the order.
$items = array();
if ( ! is_array( $purchase_data['cart_details'] ) || empty( $purchase_data['cart_details'] ) ) {
return $items;
}
$i = 0;
foreach ( $purchase_data['cart_details'] as $item ) {
$item_amount = ( $item['subtotal'] / $item['quantity'] ) - ( $item['discount'] / $item['quantity'] );
if ( $item_amount <= 0 ) {
$item_amount = 0;
}
$items[ $i ] = array(
'name' => stripslashes_deep( html_entity_decode( substr( edd_get_cart_item_name( $item ), 0, 127 ), ENT_COMPAT, 'UTF-8' ) ),
'quantity' => $item['quantity'],
'unit_amount' => array(
'currency_code' => edd_get_currency(),
'value' => (string) edd_sanitize_amount( $item_amount ),
),
'discount' => $item['discount'], // This is unset later and never sent to PayPal.
);
if ( edd_use_skus() ) {
$sku = edd_get_download_sku( $item['id'] );
if ( ! empty( $sku ) && '-' !== $sku ) {
$items[ $i ]['sku'] = $sku;
}
}
$i++;
}
return $items;
}
/**
* Attempts to detect if there's an item total mismatch. This means the individual item breakdowns don't
* add up to our proposed totals.
*
* @link https://github.com/easydigitaldownloads/easy-digital-downloads/pull/8835#issuecomment-921759101
* @internal Not intended for public use.
*
* @since 2.11.2
*
* @param object $response
*
* @return bool
*/
function _is_item_total_mismatch( $response ) {
if ( ! isset( $response->details ) || ! is_array( $response->details ) ) {
return false;
}
foreach( $response->details as $detail ) {
if ( ! empty( $detail->issue ) && 'ITEM_TOTAL_MISMATCH' === strtoupper( $detail->issue ) ) {
return true;
}
}
return false;
}

View File

@ -0,0 +1,69 @@
<?php
/**
* PayPal Commerce Gateway Filters
*
* @package easy-digital-downloads
* @subpackage Gateways\PayPal
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
*/
namespace EDD\Gateways\PayPal;
/**
* Removes PayPal Standard from the list of available gateways while we're on the EDD Settings page.
* This prevents PayPal Standard from being enabled as a gateway if:
*
* - The store owner has never used PayPal Standard; or
* - The store Owner used PayPal Standard previously but has now been onboarded to PayPal Commerce.
*
* @param array $gateways
*
* @since 2.11
* @return array
*/
function maybe_remove_paypal_standard( $gateways ) {
if ( function_exists( 'edd_is_admin_page' ) && edd_is_admin_page( 'settings' ) && ! paypal_standard_enabled() ) {
unset( $gateways['paypal'] );
}
return $gateways;
}
add_filter( 'edd_payment_gateways', __NAMESPACE__ . '\maybe_remove_paypal_standard' );
/**
* Creates a link to the transaction within PayPal.
*
* @param string $transaction_id PayPal transaction ID.
* @param int $payment_id ID of the payment.
*
* @since 2.11
* @return string
*/
function link_transaction_id( $transaction_id, $payment_id ) {
if ( empty( $transaction_id ) ) {
return $transaction_id;
}
$payment = edd_get_payment( $payment_id );
if ( ! $payment ) {
return $transaction_id;
}
$subdomain = ( 'test' === $payment->mode ) ? 'sandbox.' : '';
$transaction_url = 'https://' . urlencode( $subdomain ) . 'paypal.com/activity/payment/' . urlencode( $transaction_id );
return '<a href="' . esc_url( $transaction_url ) . '" target="_blank">' . esc_html( $transaction_id ) . '</a>';
}
add_filter( 'edd_payment_details_transaction_id-paypal_commerce', __NAMESPACE__ . '\link_transaction_id', 10, 2 );
/**
* By default, EDD_Payment converts an empty transaction ID to be the ID of the payment.
* We don't want that to happen... Empty should be empty.
*
* @since 2.11
*/
add_filter( 'edd_get_payment_transaction_id-paypal_commerce', '__return_false' );

View File

@ -0,0 +1,25 @@
<?php
/**
* PayPal Commerce Integrations
*
* @package easy-digital-downloads
* @subpackage Gateways\PayPal
* @copyright Copyright (c) 2022, Easy Digital Downloads
* @license GPL2+
* @since 3.0
*/
namespace EDD\Gateways\PayPal;
/**
* Tells Auto Register to log the user in when the PayPal Commerce action is detected.
* Added slightly early to not override anything more specific.
*
* @since 3.0
* @param bool $should_login Whether the new user shold be automatically logged in.
* @return bool
*/
function auto_register( $should_login ) {
return isset( $_POST['action'] ) && 'edd_capture_paypal_order' === $_POST['action'] ? true : $should_login;
}
add_filter( 'edd_auto_register_login_user', __NAMESPACE__ . '\auto_register', 5 );

View File

@ -0,0 +1,405 @@
<?php
/**
* IPN Functions
*
* This serves as a fallback for the webhooks in the event that the app becomes disconnected.
*
* @package easy-digital-downloads
* @subpackage Gateways\PayPal\Webhooks
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal\IPN;
/**
* Listens for an IPN call from PayPal
*
* This is intended to be a 'backup' listener, for if the webhook is no longer connected for a specific PayPal object.
*
* @since 3.1.0.3
*/
function listen_for_ipn() {
if ( empty( $_GET['edd-listener'] ) || 'eppe' !== $_GET['edd-listener'] ) {
return;
}
ipn_debug_log( 'IPN Backup Loaded' );
// Moving this up in the load order so we can check some things before even getting to verification.
$posted = $_POST;
$ignored_txn_types = array( 'recurring_payment_profile_created' );
if ( isset( $posted['txn_type'] ) && in_array( $posted['txn_type'], $ignored_txn_types ) ) {
ipn_debug_log( 'Transaction Type ' . $posted['txn_type'] . ' is ignored by the PayPal Commerce IPN.' );
return;
}
nocache_headers();
$verified = false;
// Set initial post data to empty string.
$post_data = '';
// Fallback just in case post_max_size is lower than needed.
if ( ini_get( 'allow_url_fopen' ) ) {
$post_data = file_get_contents( 'php://input' );
} else {
// If allow_url_fopen is not enabled, then make sure that post_max_size is large enough.
ini_set( 'post_max_size', '12M' );
}
// Start the encoded data collection with notification command.
$encoded_data = 'cmd=_notify-validate';
// Get current arg separator.
$arg_separator = edd_get_php_arg_separator_output();
// Verify there is a post_data.
if ( $post_data || strlen( $post_data ) > 0 ) {
// Append the data.
$encoded_data .= $arg_separator . $post_data;
} else {
// Check if POST is empty.
if ( empty( $_POST ) ) {
// Nothing to do.
ipn_debug_log( 'post data not detected, bailing' );
return;
} else {
// Loop through each POST.
foreach ( $_POST as $key => $value ) {
// Encode the value and append the data.
$encoded_data .= $arg_separator . "$key=" . urlencode( $value );
}
}
}
// Convert collected post data to an array.
parse_str( $encoded_data, $encoded_data_array );
// We're always going to validate the IPN here...
if ( ! edd_is_test_mode() ) {
ipn_debug_log( 'preparing to verify IPN data' );
// Validate the IPN.
$remote_post_vars = array(
'method' => 'POST',
'timeout' => 45,
'redirection' => 5,
'httpversion' => '1.1',
'blocking' => true,
'body' => $encoded_data_array,
'headers' => array(
'host' => 'www.paypal.com',
'connection' => 'close',
'content-type' => 'application/x-www-form-urlencoded',
'post' => '/cgi-bin/webscr HTTP/1.1',
),
);
// Get response.
$api_response = wp_remote_post( edd_get_paypal_redirect(), $remote_post_vars );
$body = wp_remote_retrieve_body( $api_response );
if ( is_wp_error( $api_response ) ) {
/* Translators: %s - IPN Verification response */
edd_record_gateway_error( __( 'IPN Error', 'easy-digital-downloads' ), sprintf( __( 'Invalid PayPal Commerce/Express IPN verification response. IPN data: %s', 'easy-digital-downloads' ), json_encode( $api_response ) ) );
ipn_debug_log( 'verification failed. Data: ' . var_export( $body, true ) );
status_header( 401 );
return; // Something went wrong.
}
if ( 'VERIFIED' !== $body ) {
/* Translators: %s - IPN Verification response */
edd_record_gateway_error( __( 'IPN Error', 'easy-digital-downloads' ), sprintf( __( 'Invalid PayPal Commerce/Express IPN verification response. IPN data: %s', 'easy-digital-downloads' ), json_encode( $api_response ) ) );
ipn_debug_log( 'verification failed. Data: ' . var_export( $body, true ) );
status_header( 401 );
return; // Response not okay.
}
// We've verified that the IPN Check passed, we can proceed with processing the IPN data sent to us.
$verified = true;
}
/**
* The processIpn() method returned true if the IPN was "VERIFIED" and false if it was "INVALID".
*/
if ( ( $verified || edd_get_option( 'disable_paypal_verification' ) ) || isset( $_POST['verification_override'] ) || edd_is_test_mode() ) {
status_header( 200 );
/**
* Note: Amounts get more properly sanitized on insert.
*
* @see EDD_Subscription::add_payment()
*/
if ( isset( $posted['amount'] ) ) {
$amount = (float) $posted['amount'];
} elseif ( isset( $posted['mc_gross'] ) ) {
$amount = (float) $posted['mc_gross'];
} else {
$amount = 0;
}
$txn_type = isset( $posted['txn_type'] ) ? strtolower( $posted['txn_type'] ) : '';
$payment_status = isset( $posted['payment_status'] ) ? strtolower( $posted['payment_status'] ) : '';
$currency_code = isset( $posted['mc_currency'] ) ? $posted['mc_currency'] : $posted['currency_code'];
$transaction_id = isset( $posted['txn_id'] ) ? $posted['txn_id'] : '';
if ( empty( $txn_type ) && empty( $payment_status ) ) {
ipn_debug_log( 'No txn_type or payment_status in the IPN, bailing' );
return;
}
// Process webhooks from recurring first, as that is where most of the missing actions will come from.
if ( class_exists( 'EDD_Recurring' ) && isset( $posted['recurring_payment_id'] ) ) {
$posted = apply_filters( 'edd_recurring_ipn_post', $_POST ); // allow $_POST to be modified.
$subscription = new \EDD_Subscription( $posted['recurring_payment_id'], true );
// Bail if this is the very first payment.
if ( date( 'Y-n-d', strtotime( $subscription->created ) ) == date( 'Y-n-d', strtotime( $posted['payment_date'] ) ) ) {
ipn_debug_log( 'IPN for subscription ' . $subscription->id . ': processing stopped because this is the initial payment.' );
return;
}
$parent_payment = edd_get_payment( $subscription->parent_payment_id );
if ( 'paypal_commerce' !== $parent_payment->gateway ) {
ipn_debug_log( 'This is not for PayPal Commerce - bailing' );
return;
}
if ( empty( $subscription->id ) || $subscription->id < 1 ) {
ipn_debug_log( 'no matching subscription found detected, bailing. Data: ' . var_export( $posted, true ) );
die( 'No subscription found' );
}
ipn_debug_log( 'Processing ' . $txn_type . ' IPN for subscription ' . $subscription->id );
// Subscriptions.
switch ( $txn_type ) :
case 'recurring_payment':
case 'recurring_payment_outstanding_payment':
$transaction_exists = edd_get_order_transaction_by( 'transaction_id', $transaction_id );
if ( ! empty( $transaction_exists ) ) {
ipn_debug_log( 'Transaction ID ' . $transaction_id . ' arlready processed.' );
return;
}
$sub_currency = edd_get_payment_currency_code( $subscription->parent_payment_id );
// verify details.
if ( ! empty( $sub_currency ) && strtolower( $currency_code ) != strtolower( $sub_currency ) ) {
// the currency code is invalid
// @TODO: Does this need a parent_id for better error organization?
/* Translators: %s - The payment data sent via the IPN */
edd_record_gateway_error( __( 'Invalid Currency Code', 'easy-digital-downloads' ), sprintf( __( 'The currency code in an IPN request did not match the site currency code. Payment data: %s', 'easy-digital-downloads' ), json_encode( $posted ) ) );
ipn_debug_log( 'subscription ' . $subscription->id . ': invalid currency code detected in IPN data: ' . var_export( $posted, true ) );
die( 'invalid currency code' );
}
if ( 'failed' === $payment_status ) {
if ( 'failing' === $subscription->status ) {
ipn_debug_log( 'Subscription ID ' . $subscription->id . ' arlready failing.' );
return;
}
$transaction_link = '<a href="https://www.paypal.com/activity/payment/' . $transaction_id . '" target="_blank">' . $transaction_id . '</a>';
/* Translators: %s - The transaction ID of the failed payment */
$subscription->add_note( sprintf( __( 'Transaction ID %s failed in PayPal', 'easy-digital-downloads' ), $transaction_link ) );
$subscription->failing();
ipn_debug_log( 'subscription ' . $subscription->id . ': payment failed in PayPal' );
die( 'Subscription payment failed' );
}
ipn_debug_log( 'subscription ' . $subscription->id . ': preparing to insert renewal payment' );
// when a user makes a recurring payment.
$payment_id = $subscription->add_payment(
array(
'amount' => $amount,
'transaction_id' => $transaction_id,
)
);
if ( ! empty( $payment_id ) ) {
ipn_debug_log( 'subscription ' . $subscription->id . ': renewal payment was recorded successfully, preparing to renew subscription' );
$subscription->renew( $payment_id );
if ( 'recurring_payment_outstanding_payment' === $txn_type ) {
/* Translators: %s - The collected outstanding balance of the subscription */
$subscription->add_note( sprintf( __( 'Outstanding subscription balance of %s collected successfully.', 'easy-digital-downloads' ), $amount ) );
}
} else {
ipn_debug_log( 'subscription ' . $subscription->id . ': renewal payment creation appeared to fail.' );
}
die( 'Subscription payment successful' );
break;
case 'recurring_payment_profile_cancel':
case 'recurring_payment_suspended':
case 'recurring_payment_suspended_due_to_max_failed_payment':
if ( 'cancelled' === $subscription->status ) {
ipn_debug_log( 'Subscription ID ' . $subscription->id . ' arlready cancelled.' );
return;
}
$subscription->cancel();
ipn_debug_log( 'subscription ' . $subscription->id . ': subscription cancelled.' );
die( 'Subscription cancelled' );
break;
case 'recurring_payment_failed':
if ( 'failing' === $subscription->status ) {
ipn_debug_log( 'Subscription ID ' . $subscription->id . ' arlready failing.' );
return;
}
$subscription->failing();
ipn_debug_log( 'subscription ' . $subscription->id . ': subscription failing.' );
do_action( 'edd_recurring_payment_failed', $subscription );
break;
case 'recurring_payment_expired':
if ( 'completed' === $subscription->status ) {
ipn_debug_log( 'Subscription ID ' . $subscription->id . ' arlready completed.' );
return;
}
$subscription->complete();
ipn_debug_log( 'subscription ' . $subscription->id . ': subscription completed.' );
die( 'Subscription completed' );
break;
endswitch;
}
// We've processed recurring, now let's handle non-recurring IPNs.
// First, if this isn't a refund or reversal, we don't need to process anything.
$statuses_to_process = array( 'refunded', 'reversed' );
if ( ! in_array( $payment_status, $statuses_to_process, true ) ) {
ipn_debug_log( 'Payment Status was not a status we need to process: ' . $payment_status );
return;
}
$order_id = 0;
if ( ! empty( $posted['parent_txn_id'] ) ) {
$order_id = edd_get_order_id_from_transaction_id( $posted['parent_txn_id'] );
}
/**
* This section of the IPN should only affect processing refunds or returns on orders made previous, not new
* orders, so we can just look for the parent_txn_id, and if it's not here, bail as this is a new order that
* should be handeled with webhooks.
*/
if ( empty( $order_id ) ) {
ipn_debug_log( 'IPN Track ID ' . $posted['ipn_track_id'] . ' does not need to be processed as it does not belong to an existing record.' );
return;
}
$order = edd_get_order( $order_id );
if ( 'paypal_commerce' !== $order->gateway ) {
ipn_debug_log( 'Order ' . $order_id . ' was not with PayPal Commerce' );
return;
}
if ( 'refunded' === $order->status ) {
ipn_debug_log( 'Order ' . $order_id . ' is already refunded' );
}
$transaction_exists = edd_get_order_transaction_by( 'transaction_id', $transaction_id );
if ( ! empty( $transaction_exists ) ) {
ipn_debug_log( 'Refund transaction for ' . $transaction_id . ' already exists' );
return;
}
$order_amount = edd_get_payment_amount( $order->id );
$refunded_amount = ! empty( $amount ) ? $amount : $order_amount;
$currency = ! empty( $currency_code ) ? $currency_code : $order->currency;
ipn_debug_log( 'Processing a refund for original transaction ' . $order->get_transaction_id() );
/* 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( $transaction_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' );
}
die( 'Refund processed' );
} else {
ipn_debug_log( 'verification failed, bailing.' );
status_header( 400 );
die( 'invalid IPN' );
}
}
add_action( 'init', __NAMESPACE__ . '\listen_for_ipn' );
/**
* Helper method to prefix any calls to edd_debug_log
*
* @since 3.1.0.3
* @uses edd_debug_log
*
* @param string $message The message to send to the debug logging.
*/
function ipn_debug_log( $message ) {
edd_debug_log( 'PayPal Commerce IPN: ' . $message );
}

View File

@ -0,0 +1,79 @@
<?php
/**
* PayPal Commerce Gateway
*
* Loads all required files for PayPal Commerce. This gateway uses:
*
* Onboarding: "Build Onboarding into Software"
* @link https://developer.paypal.com/docs/platforms/seller-onboarding/build-onboarding/
*
* JavaScript SDK
* @link https://developer.paypal.com/docs/business/javascript-sdk/javascript-sdk-reference/
*
* - REST API
* @link https://developer.paypal.com/docs/api/overview/
*
* @package easy-digital-downloads
* @subpackage Gateways\PayPal
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal;
/**
* Partner attribution ID
*
* @link https://developer.paypal.com/docs/api/reference/api-requests/#paypal-partner-attribution-id
*/
if ( ! defined( 'EDD_PAYPAL_PARTNER_ATTRIBUTION_ID' ) ) {
define( 'EDD_PAYPAL_PARTNER_ATTRIBUTION_ID', 'EasyDigitalDownloadsLLC_PPFM_pcp' );
}
/**
* Partner merchant ID
*/
if ( ! defined( 'EDD_PAYPAL_MERCHANT_ID' ) ) {
define( 'EDD_PAYPAL_MERCHANT_ID', 'GFJPUJ4SNZYJN' );
}
if ( ! defined( 'EDD_PAYPAL_SANDBOX_MERCHANT_ID' ) ) {
define( 'EDD_PAYPAL_SANDBOX_MERCHANT_ID', 'NUGJTUUBANR46' );
}
/**
* Include PayPal gateway files
*/
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/exceptions/class-api-exception.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/exceptions/class-authentication-exception.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/exceptions/class-gateway-exception.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/exceptions/class-invalid-merchant-details.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/exceptions/class-missing-merchant-details.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/buy-now.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/checkout-actions.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/class-account-status-validator.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/class-merchant-account.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/class-paypal-api.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/class-token.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/deprecated.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/functions.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/gateway-filters.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/refunds.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/scripts.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/webhooks/class-webhook-handler.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/webhooks/class-webhook-validator.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/webhooks/functions.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/webhooks/events/abstract-webhook-event.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/webhooks/events/class-payment-capture-completed.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/webhooks/events/class-payment-capture-denied.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/webhooks/events/class-payment-capture-refunded.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/integrations.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/ipn.php';
if ( is_admin() ) {
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/admin/connect.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/admin/notices.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/admin/scripts.php';
require_once EDD_PLUGIN_DIR . 'includes/gateways/paypal/admin/settings.php';
}

View File

@ -0,0 +1,242 @@
<?php
/**
* PayPal Commerce Refunds
*
* @package easy-digital-downloads
* @subpackage Gateways\PayPal
* @copyright Copyright (c) 2021, Sandhills Development, LLC
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal;
use EDD\Gateways\PayPal\Exceptions\API_Exception;
use EDD\Gateways\PayPal\Exceptions\Authentication_Exception;
use EDD\Orders\Order;
/**
* Shows a checkbox to automatically refund payments in PayPal.
*
* @param Order $order
*
* @since 3.0
*/
add_action( 'edd_after_submit_refund_table', function( Order $order ) {
if ( 'paypal_commerce' !== $order->gateway ) {
return;
}
$mode = ( 'live' === $order->mode ) ? API::MODE_LIVE : API::MODE_SANDBOX;
try {
new API( $mode );
} catch ( Exceptions\Authentication_Exception $e ) {
// If we don't have credentials.
return;
}
?>
<div class="edd-form-group edd-paypal-refund-transaction">
<div class="edd-form-group__control">
<input type="checkbox" id="edd-paypal-commerce-refund" name="edd-paypal-commerce-refund" class="edd-form-group__input" value="1">
<label for="edd-paypal-commerce-refund" class="edd-form-group__label">
<?php esc_html_e( 'Refund transaction in PayPal', 'easy-digital-downloads' ); ?>
</label>
</div>
</div>
<?php
} );
/**
* If selected, refunds a transaction in PayPal when creating a new refund record.
*
* @param int $order_id ID of the order we're processing a refund for.
* @param int $refund_id ID of the newly created refund record.
* @param bool $all_refunded Whether or not this was a full refund.
*
* @since 3.0
*/
add_action( 'edd_refund_order', function( $order_id, $refund_id, $all_refunded ) {
if ( ! current_user_can( 'edit_shop_payments', $order_id ) ) {
return;
}
if ( empty( $_POST['data'] ) ) {
return;
}
$order = edd_get_order( $order_id );
if ( empty( $order->gateway ) || 'paypal_commerce' !== $order->gateway ) {
return;
}
// Get our data out of the serialized string.
parse_str( $_POST['data'], $form_data );
if ( empty( $form_data['edd-paypal-commerce-refund'] ) ) {
edd_add_note( array(
'object_id' => $order_id,
'object_type' => 'order',
'user_id' => is_admin() ? get_current_user_id() : 0,
'content' => __( 'Transaction not refunded in PayPal, as checkbox was not selected.', 'easy-digital-downloads' )
) );
return;
}
$refund = edd_get_order( $refund_id );
if ( empty( $refund->total ) ) {
return;
}
try {
refund_transaction( $order, $refund );
} catch ( \Exception $e ) {
edd_debug_log( sprintf(
'Failure when processing refund #%d. Message: %s',
$refund->id,
$e->getMessage()
), true );
edd_add_note( array(
'object_id' => $order->id,
'object_type' => 'order',
'user_id' => is_admin() ? get_current_user_id() : 0,
'content' => sprintf(
/* Translators: %d - ID of the refund; %s - error message from PayPal */
__( 'Failure when processing PayPal refund #%d: %s', 'easy-digital-downloads' ),
$refund->id,
$e->getMessage()
)
) );
}
}, 10, 3 );
/**
* Refunds a transaction in PayPal.
*
* @link https://developer.paypal.com/docs/api/payments/v2/#captures_refund
*
* @param \EDD_Payment|Order $payment_or_order
* @param Order|null $refund_object
*
* @since 2.11
* @throws Authentication_Exception
* @throws API_Exception
* @throws \Exception
*/
function refund_transaction( $payment_or_order, Order $refund_object = null ) {
/*
* Internally we want to work with an Order object, but we also need
* an EDD_Payment object for backwards compatibility in the hooks.
*/
$order = $payment = false;
if ( $payment_or_order instanceof Order ) {
$order = $payment_or_order;
$payment = edd_get_payment( $order->id );
} elseif ( $payment_or_order instanceof \EDD_Payment ) {
$payment = $payment_or_order;
$order = edd_get_order( $payment->ID );
}
if ( empty( $order ) || ! $order instanceof Order ) {
return;
}
$transaction_id = $order->get_transaction_id();
if ( empty( $transaction_id ) ) {
throw new \Exception( __( 'Missing transaction ID.', 'easy-digital-downloads' ) );
}
$mode = ( 'live' === $order->mode ) ? API::MODE_LIVE : API::MODE_SANDBOX;
$api = new API( $mode );
$args = $refund_object instanceof Order ? array( 'invoice_id' => $refund_object->id ) : array();
if ( $refund_object instanceof Order && abs( $refund_object->total ) !== abs( $order->total ) ) {
$args['amount'] = array(
'value' => abs( $refund_object->total ),
'currency_code' => $refund_object->currency,
);
}
$response = $api->make_request(
'v2/payments/captures/' . urlencode( $transaction_id ) . '/refund',
$args,
array(
'Prefer' => 'return=representation',
)
);
if ( 201 !== $api->last_response_code ) {
throw new API_Exception( sprintf(
/* Translators: %d - The HTTP response code; %s - Full API response from PayPal */
__( 'Unexpected response code: %d. Response: %s', 'easy-digital-downloads' ),
$api->last_response_code,
json_encode( $response )
), $api->last_response_code );
}
if ( empty( $response->status ) || 'COMPLETED' !== strtoupper( $response->status ) ) {
throw new API_Exception( sprintf(
/* Translators: %s - API response from PayPal */
__( 'Missing or unexpected refund status. Response: %s', 'easy-digital-downloads' ),
json_encode( $response )
) );
}
// At this point we can assume it was successful.
edd_update_order_meta( $order->id, '_edd_paypal_refunded', true );
if ( ! empty( $response->id ) ) {
// Add a note to the original order, and, if provided, the new refund object.
if ( isset( $response->amount->value ) ) {
$note_message = sprintf(
/* Translators: %1$s - amount refunded; %$2$s - transaction ID. */
__( '%1$s refunded in PayPal. Refund transaction ID: %2$s', 'easy-digital-downloads' ),
edd_currency_filter( edd_format_amount( $response->amount->value ), $order->currency ),
esc_html( $response->id )
);
} else {
$note_message = sprintf(
/* Translators: %s - ID of the refund in PayPal */
__( 'Successfully refunded in PayPal. Refund transaction ID: %s', 'easy-digital-downloads' ),
esc_html( $response->id )
);
}
$note_object_ids = array( $order->id );
if ( $refund_object instanceof Order ) {
$note_object_ids[] = $refund_object->id;
}
foreach ( $note_object_ids as $note_object_id ) {
edd_add_note( array(
'object_id' => $note_object_id,
'object_type' => 'order',
'user_id' => is_admin() ? get_current_user_id() : 0,
'content' => $note_message
) );
}
// Add a negative transaction.
if ( $refund_object instanceof Order && isset( $response->amount->value ) ) {
edd_add_order_transaction( array(
'object_id' => $refund_object->id,
'object_type' => 'order',
'transaction_id' => sanitize_text_field( $response->id ),
'gateway' => 'paypal_commerce',
'status' => 'complete',
'total' => edd_negate_amount( $response->amount->value ),
) );
}
}
/**
* Triggers after a successful refund.
*
* @param \EDD_Payment $payment
*/
do_action( 'edd_paypal_refund_purchase', $payment );
}

View File

@ -0,0 +1,190 @@
<?php
/**
* PayPal Commerce Scripts
*
* @package Sandhills Development, LLC
* @subpackage Gateways\PayPal
* @copyright Copyright (c) 2021, Ashley Gibson
* @license GPL2+
* @since 2.11
*/
namespace EDD\Gateways\PayPal;
use EDD\Gateways\PayPal\Exceptions\Authentication_Exception;
/**
* Enqueues polyfills for Promise and Fetch.
*
* @since 2.11
*/
function maybe_enqueue_polyfills() {
/**
* Filters whether or not IE11 polyfills should be loaded.
* Note: This filter may have its default changed at any time, or may entirely
* go away at one point.
*
* @since 2.11
*/
if ( ! apply_filters( 'edd_load_ie11_polyfills', true ) ) {
return;
}
global $wp_version;
if ( version_compare( $wp_version, '5.0', '>=' ) ) {
wp_enqueue_script( 'wp-polyfill' );
} else {
wp_enqueue_script(
'wp-polyfill',
EDD_PLUGIN_URL . 'assets/js/wp-polyfill.min.js',
array(),
false,
false
);
}
}
/**
* Registers PayPal JavaScript
*
* @param bool $force_load
*
* @since 2.11
* @return void
*/
function register_js( $force_load = false ) {
if ( ! edd_is_gateway_active( 'paypal_commerce' ) ) {
return;
}
if ( ! ready_to_accept_payments() ) {
return;
}
try {
$api = new API();
} catch ( Authentication_Exception $e ) {
return;
}
/**
* Filters the query arguments added to the SDK URL.
*
* @link https://developer.paypal.com/docs/checkout/reference/customize-sdk/#query-parameters
*
* @since 2.11
*/
$sdk_query_args = apply_filters( 'edd_paypal_js_sdk_query_args', array(
'client-id' => urlencode( $api->client_id ),
'currency' => urlencode( strtoupper( edd_get_currency() ) ),
'intent' => 'capture',
'disable-funding' => 'card,credit,bancontact,blik,eps,giropay,ideal,mercadopago,mybank,p24,sepa,sofort,venmo'
) );
wp_register_script(
'sandhills-paypal-js-sdk',
esc_url_raw( add_query_arg( array_filter( $sdk_query_args ), 'https://www.paypal.com/sdk/js' ) )
);
wp_register_script(
'edd-paypal',
EDD_PLUGIN_URL . 'assets/js/paypal-checkout.js',
array(
'sandhills-paypal-js-sdk',
'jquery',
'edd-ajax'
),
EDD_VERSION,
true
);
if ( edd_is_checkout() || $force_load ) {
maybe_enqueue_polyfills();
wp_enqueue_script( 'sandhills-paypal-js-sdk' );
wp_enqueue_script( 'edd-paypal' );
$paypal_script_vars = array(
/**
* Filters the order approval handler.
*
* @since 2.11
*/
'approvalAction' => apply_filters( 'edd_paypal_on_approve_action', 'edd_capture_paypal_order' ),
'defaultError' => edd_build_errors_html( array(
'paypal-error' => esc_html__( 'An unexpected error occurred. Please try again.', 'easy-digital-downloads' )
) ),
'intent' => ! empty( $sdk_query_args['intent'] ) ? $sdk_query_args['intent'] : 'capture',
'style' => get_button_styles(),
);
wp_localize_script( 'edd-paypal', 'eddPayPalVars', $paypal_script_vars );
}
}
add_action( 'wp_enqueue_scripts', __NAMESPACE__ . '\register_js', 100 );
/**
* Removes the "?ver=" query arg from the PayPal JS SDK URL, because PayPal will throw an error
* if it's included.
*
* @param string $url
*
* @since 2.11
* @return string
*/
function remove_ver_query_arg( $url ) {
$sdk_url = 'https://www.paypal.com/sdk/js';
if ( false !== strpos( $url, $sdk_url ) ) {
$new_url = preg_split( "/(&ver|\?ver)/", $url );
return $new_url[0];
}
return $url;
}
add_filter( 'script_loader_src', __NAMESPACE__ . '\remove_ver_query_arg', 100 );
/**
* Adds data attributes to the PayPal JS SDK <script> tag.
*
* @link https://developer.paypal.com/docs/checkout/reference/customize-sdk/#script-parameters
*
* @since 2.11
*
* @param string $script_tag HTML <script> tag.
* @param string $handle Registered handle.
* @param string $src Script SRC value.
*
* @return string
*/
function add_data_attributes( $script_tag, $handle, $src ) {
if ( 'sandhills-paypal-js-sdk' !== $handle ) {
return $script_tag;
}
/**
* Filters the data attributes to add to the <script> tag.
*
* @since 2.11
*
* @param array $data_attributes
*/
$data_attributes = apply_filters( 'edd_paypal_js_sdk_data_attributes', array(
'partner-attribution-id' => EDD_PAYPAL_PARTNER_ATTRIBUTION_ID
) );
if ( empty( $data_attributes ) || ! is_array( $data_attributes ) ) {
return $script_tag;
}
$formatted_attributes = array_map( function ( $key, $value ) {
return sprintf( 'data-%s="%s"', sanitize_html_class( $key ), esc_attr( $value ) );
}, array_keys( $data_attributes ), $data_attributes );
return str_replace( ' src', ' ' . implode( ' ', $formatted_attributes ) . ' src', $script_tag );
}
add_filter( 'script_loader_tag', __NAMESPACE__ . '\add_data_attributes', 10, 3 );

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