diff --git a/classes/gateways/class.pmprogateway_paypalrest.php b/classes/gateways/class.pmprogateway_paypalrest.php
new file mode 100644
index 000000000..396c3434e
--- /dev/null
+++ b/classes/gateways/class.pmprogateway_paypalrest.php
@@ -0,0 +1,1220 @@
+ true,
+ 'payment_method_updates' => false,
+ 'check_token_orders' => true,
+ );
+
+ if ( empty( $supports[$feature] ) ) {
+ return false;
+ }
+
+ return $supports[$feature];
+ }
+
+ /**
+ * Check if the PayPal REST beta is enabled.
+ *
+ * @since TBD
+ *
+ * @return bool True if the PayPal REST beta is enabled, false otherwise.
+ */
+ private static function beta_enabled() {
+ return defined( 'PMPRO_PAYPALREST_BETA' ) && PMPRO_PAYPALREST_BETA;
+ }
+
+ /**
+ * Add PayPal REST to the list of gateways.
+ *
+ * @since TBD
+ *
+ * @param array $gateways The list of gateway options.
+ * @return array The updated list of gateway options.
+ */
+ public static function pmpro_gateways( $gateways ) {
+ // For now, if the beta is not enabled, don't show the gateway.
+ if ( ! self::beta_enabled() ) {
+ return $gateways;
+ }
+
+ $gateways['paypalrest'] = 'PayPal REST (Beta)';
+ return $gateways;
+ }
+
+ /**
+ * Get a list of payment options that the PayPal REST gateway needs/supports.
+ * Note: This function needs to exist for the currency and tax settings to show.
+ *
+ * @since TBD
+ */
+ public static function getGatewayOptions() {
+ $options = array(
+ 'gateway_environment',
+ 'currency',
+ 'tax_state',
+ 'tax_rate',
+ );
+
+ return $options;
+ }
+
+ /**
+ * Set payment options for payment settings page.
+ *
+ * @since TBD
+ *
+ * @param array $options The list of payment options.
+ * @return array The updated list of payment options.
+ */
+ public static function pmpro_payment_options( $options ) {
+ // Get the list of gateway options.
+ $paypalrest_options = self::getGatewayOptions();
+
+ return array_merge( $options, $paypalrest_options );
+ }
+
+ /**
+ * Display fields for PayPal REST settings.
+ *
+ * @since TBD
+ *
+ * @param array $values The current values of the fields.
+ * @param string $gateway The current gateway.
+ */
+ public static function pmpro_payment_option_fields( $values, $gateway ) {
+ self::show_environment_fields( 'live', $gateway === 'paypalrest' );
+ self::show_environment_fields( 'sandbox', $gateway === 'paypalrest' );
+ ?>
+
+
+
style="display: none;">
+
+
+
+ |
+
+
+ style="display: none;">
+
+
+ |
+
+ $nonce,
+ 'environment' => $environment,
+ ), 'https://connect.paidmembershipspro.com/paypal/v1' );
+ $paypal_script_callback_name = 'pmpro_paypalrest_oauth_callback_' . $environment;
+ ?>
+
+ Connect to PayPal
+ |
+
+
+ style="display: none;">
+
+ :
+ |
+
+
+ |
+
+ style="display: none;">
+
+ :
+ |
+
+
+ |
+
+ style="display: none;">
+
+
+ |
+
+
+
+ |
+
style="display: none;">
+
+
+ |
+
+ style="display: none;">
+
+ :
+ |
+
+
+ |
+
+ style="display: none;">
+ name;
+ }, $webhook_object->event_types );
+ $required_events = self::get_required_webhook_events();
+ if ( self::get_site_webhook_url() !== $webhook_object->url ) {
+ // The webhook URL is incorrect. Show a warning message.
+ $webhook_status_label = __( 'Webhook URL Incorrect', 'paid-memberships-pro' );
+ $webhook_status_checkbox_small = __( 'Check this box to create a new webhook.', 'paid-memberships-pro' );
+ } elseif ( count( array_diff( $required_events, $webhook_events ) ) > 0 ) {
+ // The webhook events are incorrect. Show a warning message.
+ $webhook_status_label = __( 'Webhook Events Incorrect', 'paid-memberships-pro' );
+ $webhook_status_checkbox_small = __( 'Check this box to fix the webhook events.', 'paid-memberships-pro' );
+ } else {
+ // The webhook is set up correctly. Show a success message.
+ $webhook_status_label = __( 'Webhook Connected', 'paid-memberships-pro' );
+ }
+ }
+ }
+ ?>
+
+
+
+ :
+ |
+
+
+ :
+
+
+
+
+
+
+ |
+
+
+
+ :
+ |
+
+
+ |
+
+
+ style="display: none;">
+
+ '
+ );
+ ?>
+
+
+
+
+ style="display: none;">
+
+
+
+ status = 'token';
+ $order->saveOrder();
+ pmpro_save_checkout_data_to_order( $order );
+
+ // Get the membership being purchased.
+ $level = $order->getMembershipLevelAtCheckout();
+
+ // Calculate the initial payment amount with tax.
+ $initial_subtotal = $order->subtotal;
+ $initial_tax = $order->getTaxForPrice( $initial_subtotal );
+ $initial_payment_amount = pmpro_round_price( (float) $initial_subtotal + (float) $initial_tax );
+
+ // We need to handle one-time payments and subscriptions differently.
+ $error = null;
+ if ( ! pmpro_isLevelRecurring( $level ) ) {
+ // Sending the user to PayPal for a one-time payment.
+ $response = self::send_request(
+ 'POST',
+ 'v2/checkout/orders',
+ array(
+ 'intent' => 'CAPTURE',
+ 'purchase_units' => array(
+ array(
+ 'amount' => array(
+ 'currency_code' => 'USD',
+ 'value' => (string) $initial_payment_amount,
+ ),
+ ),
+ ),
+ 'payment_source' => array(
+ 'paypal' => array(
+ 'experience_context' => array(
+ 'payment_method_preference' => 'IMMEDIATE_PAYMENT_REQUIRED',
+ 'shipping_preference' => 'NO_SHIPPING',
+ 'user_action' => 'PAY_NOW',
+ 'return_url' => apply_filters( 'pmpro_confirmation_url', add_query_arg( 'pmpro_level', $level->id, pmpro_url("confirmation" ) ), $order->user_id, $level ),
+ 'cancel_url' => add_query_arg( 'pmpro_level', $level->id, pmpro_url("checkout" ) ),
+ ),
+ ),
+ )
+ )
+ );
+
+ // If we didn't get an error string, redirect the user to PayPal to pay.
+ if ( ! is_string( $response ) ) {
+ // Save the order ID so that we can complete the order later.
+ update_pmpro_membership_order_meta( $order->id, 'paypalrest_order_id', $response->id );
+
+ // Find the payer action link and redirect the user to it.
+ $links = $response->links;
+ foreach ( $links as $link ) {
+ if ( $link->rel === 'payer-action' ) {
+ wp_redirect( $link->href );
+ exit;
+ }
+ }
+
+ // If we didn't find an approve link, return an error message.
+ $error = __( 'Could not find a payer action link.', 'paid-memberships-pro' );
+ }
+
+ // If we got an error string, save it to display to the user.
+ $error = $response;
+ } else {
+ // Sending the user to PayPal for a subscription.
+ // First, get the product ID for the level.
+ $product_id = self::get_product_id_for_level( $level->id );
+ if ( ! $product_id ) {
+ // If we couldn't get the product ID, return an error message.
+ $error = __( 'Error creating product.', 'paid-memberships-pro' );
+ }
+
+ // Next, get the plan ID for the product.
+ if ( empty( $error ) ) {
+ // Calculate the recurring payment amount with tax.
+ $recurring_subtotal = $level->billing_amount;
+ $recurring_tax = $order->getTaxForPrice( $recurring_subtotal );
+ $recurring_payment_amount = pmpro_round_price( (float) $recurring_subtotal + (float) $recurring_tax );
+
+ // Calculate the trial payment amount with tax.
+ $trial_subtotal = $level->trial_amount;
+ $trial_tax = $order->getTaxForPrice( $trial_subtotal );
+ $trial_amount = pmpro_round_price( (float) $trial_subtotal + (float) $trial_tax );
+
+ $plan_id = self::get_plan_for_product( $product_id, $initial_payment_amount, $recurring_payment_amount, $level->cycle_period, $level->cycle_number, $trial_amount, $level->trial_limit, $level->name );
+ if ( ! $plan_id ) {
+ // If we couldn't get the plan ID, return an error message.
+ $error = __( 'Error creating plan.', 'paid-memberships-pro' );
+ }
+ }
+
+ // Finally, create the subscription.
+ if ( empty( $error ) ) {
+ $response = self::send_request(
+ 'POST',
+ 'v1/billing/subscriptions',
+ array(
+ 'plan_id' => $plan_id,
+ 'start_time' => pmpro_calculate_profile_start_date( $order, 'c' ),
+ 'application_context' => array(
+ 'brand_name' => get_bloginfo( 'name' ),
+ 'shipping_preference' => 'NO_SHIPPING',
+ 'user_action' => 'SUBSCRIBE_NOW',
+ 'return_url' => apply_filters( 'pmpro_confirmation_url', add_query_arg( 'pmpro_level', $level->id, pmpro_url( 'confirmation' ) ), $order->user_id, $level ),
+ 'cancel_url' => add_query_arg( 'pmpro_level', $level->id, pmpro_url( 'checkout' ) ),
+ ),
+ )
+ );
+
+ // If we didn't get an error string, redirect the user to PayPal.
+ if ( ! is_string( $response ) ) {
+ // Save the subscription ID so that we can complete the order later.
+ $order->subscription_transaction_id = $response->id;
+ $order->saveOrder();
+
+ // Find the approve link and redirect the user to it.
+ $links = $response->links;
+ foreach ( $links as $link ) {
+ if ( $link->rel === 'approve' ) {
+ wp_redirect( $link->href );
+ exit;
+ }
+ }
+
+ // If we didn't find an approve link, return an error message.
+ $error = __( 'Could not find an approve link.', 'paid-memberships-pro' );
+ }
+
+ // If we got an error string, save it to display to the user.
+ $error = $response;
+ }
+ }
+
+ // If we got an error, save it to the order and redirect the user to the error page.
+ if ( ! empty( $error ) ) {
+ $order->error = $error;
+ $order->shorterror = $error;
+ }
+
+ return false;
+ }
+
+ /**
+ * Cancel a subscription in PayPal.
+ *
+ * @param PMPro_Subscription $subscription The subscription to cancel.
+ * @return bool False if we could not confirm that the subscription was cancelled at the gateway.
+ */
+ function cancel_subscription( $subscription ) {
+ // Send the request to cancel the subscription.
+ $response = self::send_request(
+ 'POST',
+ 'v1/billing/subscriptions/' . $subscription->get_subscription_transaction_id() . '/cancel',
+ array(),
+ $subscription->get_gateway_environment()
+ );
+
+ // If we got an error, save it to the subscription.
+ if ( is_string( $response ) ) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Pull subscription info from Stripe.
+ *
+ * @param PMPro_Subscription $subscription to pull data for.
+ *
+ * @return string|null Error message is returned if update fails.
+ */
+ public function update_subscription_info( $subscription ) {
+ // Get the subscription from PayPal.
+ $response = self::send_request(
+ 'GET',
+ 'v1/billing/subscriptions/' . $subscription->get_subscription_transaction_id(),
+ array(),
+ $subscription->get_gateway_environment()
+ );
+ if ( is_string( $response ) ) {
+ // Couldn't get the subscription. Bail.
+ return $response;
+ }
+
+ // Update the subscription with the new data.
+ $paypal_subscription = $response;
+ $update_array = array(
+ 'startdate' => date( 'Y-m-d H:i:s', strtotime( $paypal_subscription->create_time ) ),
+ );
+ if ( 'ACTIVE' === $paypal_subscription->status ) {
+ // Subscription is active.
+ $update_array['status'] = 'active';
+
+ // Get the next payment date.
+ $update_array['next_payment_date'] = date( 'Y-m-d H:i:s', strtotime( $paypal_subscription->billing_info->next_billing_time ) );
+
+ // Get the plan for the subscription.
+ $response = self::send_request(
+ 'GET',
+ 'v1/billing/plans/' . $paypal_subscription->plan_id,
+ array(),
+ $subscription->get_gateway_environment()
+ );
+ if ( is_string( $response ) ) {
+ // Couldn't get the plan. Let's save what we got and bail.
+ $subscription->set( $update_array );
+ return $response;
+ }
+
+ $paypal_plan = $response;
+ foreach( $paypal_plan->billing_cycles as $billing_cycle ) {
+ if ( 'REGULAR' === $billing_cycle->tenure_type ) {
+ $update_array['billing_amount'] = $billing_cycle->pricing_scheme->fixed_price->value;
+ $update_array['cycle_number'] = $billing_cycle->frequency->interval_count;
+ $update_array['cycle_period'] = ucfirst( strtolower( $billing_cycle->frequency->interval_unit ) );
+ } elseif ( 'TRIAL' === $billing_cycle->tenure_type ) {
+ $update_array['trial_amount'] = $billing_cycle->pricing_scheme->fixed_price->value;
+ $update_array['trial_limit'] = $billing_cycle->total_cycles;
+ }
+ }
+ } else {
+ // Subscription is no longer active.
+ $update_array['status'] = 'cancelled';
+ $update_array['enddate'] = date( 'Y-m-d H:i:s', strtotime( $paypal_subscription->status_update_time ) );
+ }
+
+ // Save the updated subscription.
+ $subscription->set( $update_array );
+ }
+
+ /**
+ * Refunds an order (only supports full amounts).
+ *
+ * @since TBD
+ *
+ * @param bool $success Status of the refund (default: false).
+ * @param MemberOrder $order The order being refunded.
+ *
+ * @return bool True if the refund was successful, false otherwise.
+ */
+ public static function process_refund( $success, $order ) {
+ // If we've already somehow processed a refund, bail.
+ if ( $success ) {
+ return $success;
+ }
+
+ // If we don't have a transaction ID, bail.
+ if ( empty( $order->payment_transaction_id ) ) {
+ return false;
+ }
+
+ // Send the request to refund the payment.
+ $response = self::send_request(
+ 'POST',
+ 'v2/payments/captures/' . $order->payment_transaction_id . '/refund',
+ array(),
+ $order->gateway_environment
+ );
+
+ // If we got an error string, save it to order notes.
+ if ( is_string( $response ) ) {
+ $order->notes .= "\n" . __( 'Error processing refund:', 'paid-memberships-pro' ) . ' ' . $response;
+ $order->saveOrder();
+ return false;
+ }
+
+ // If we got a successful response, set the order status to refunded.
+ $order->status = 'refunded';
+ $order->saveOrder();
+ return true;
+
+ }
+
+ /**
+ * Check whether the payment for a token order has been completed. If so, process the order.
+ *
+ * @param MemberOrder $order The order object to check.
+ * @return true|string True if the payment has been completed and the order processed. A string if an error occurred.
+ */
+ function check_token_order( $order ) {
+ // If this is not a token order, bail.
+ if ( 'token' !== $order->status ) {
+ return __( 'This is not a token order.', 'paid-memberships-pro' );
+ }
+
+ // Check if we have a PayPal order ID in order meta. If so, this is a one-time payment.
+ $paypal_order_id = get_pmpro_membership_order_meta( $order->id, 'paypalrest_order_id', true );
+
+ // If we don't have a PayPal order ID or a subscription transaction ID, we can't link this order to a checkout. Bail.
+ if ( empty( $paypal_order_id ) && empty( $order->subscription_transaction_id ) ) {
+ return __( 'No PayPal order ID or subscription transaction ID found.', 'paid-memberships-pro' );
+ }
+
+ // Get information about the checkout from PayPal.
+ if ( ! empty( $paypal_order_id ) ) {
+ // Processing a one-time payment.
+ // Get the order from PayPal.
+ $paypal_order = self::send_request( 'GET', 'v2/checkout/orders/' . $paypal_order_id, array(), $order->gateway_environment );
+ if ( is_string( $paypal_order ) ) {
+ // An error string was returned. Record it.
+ return __( 'Could not get order information.', 'paid-memberships-pro' ) . ' ' . $paypal_order;
+ }
+
+ // If we have a PayPal order that still needs to be captured, do so.
+ if ( 'APPROVED' === $paypal_order->status ) {
+ $response = self::send_request( 'POST', 'v2/checkout/orders/' . $paypal_order->id . '/capture', array(), $order->gateway_environment );
+ if ( is_string( $response ) ) {
+ // An error string was returned. Record it.
+ return __( 'Could not capture payment.', 'paid-memberships-pro' ) . ' ' . $response;
+ } else {
+ // The payment was captured successfully. Update $resource with the new data.
+ $paypal_order = $response;
+ }
+ }
+
+ // If the order is not in the COMPLETED status, bail.
+ if ( 'COMPLETED' !== $paypal_order->status ) {
+ return __( 'Order is not yet completed.', 'paid-memberships-pro' );
+ }
+
+ // Set the payment transaction ID.
+ $order->payment_transaction_id = $paypal_order->purchase_units[0]->payments->captures[0]->id;
+ } else {
+ // Processing a subscription payment.
+ // Get the subscription from PayPal.
+ $subscription = self::send_request(
+ 'GET',
+ 'v1/billing/subscriptions/' . $order->subscription_transaction_id,
+ array(),
+ $order->gateway_environment
+ );
+ if ( is_string( $subscription ) ) {
+ // Couldn't get the subscription. Bail.
+ return __( 'Could not get subscription information.', 'paid-memberships-pro' ) . ' ' . $subscription;
+ }
+
+ // If the subscription isn't active, bail.
+ if ( 'ACTIVE' !== $subscription->status ) {
+ return __( 'Subscription is not yet active.', 'paid-memberships-pro' );
+ }
+
+ // The subscription is active. Get the payment transaction ID if there was an initial payment and continue completing the checkout.
+ $subscription_transactions = self::send_request( 'GET', 'v1/billing/subscriptions/' . $subscription->id . '/transactions/?' . http_build_query( array( 'start_time' => date( 'c', strtotime( $subscription->create_time ) - 3600 ), 'end_time' => date( 'c', strtotime( $subscription->create_time ) + 3600 ) ) ), array(), $order->gateway_environment );
+ if ( ! is_string( $subscription_transactions ) && ! empty( $subscription_transactions->transactions ) ) {
+ // There is an initial payment. Update the order with the payment transaction ID.
+ $order->payment_transaction_id = $subscription_transactions->transactions[0]->id;
+ }
+ }
+
+ // Complete the checkout.
+ pmpro_pull_checkout_data_from_order( $order );
+ return pmpro_complete_async_checkout( $order );
+ }
+
+ /**
+ * Send a request to the PayPal API.
+ *
+ * @since TBD
+ *
+ * @param string $method The HTTP method to use.
+ * @param string $endpoint_url The endpoint URL to send the request to (excluding the base URL).
+ * @param array $body The body to send with the request.
+ * @param string $gateway_environment The environment to use for the request. If empty, the current environment will be used.
+ *
+ * @return object|string The response from the request or an error message.
+ */
+ public static function send_request( $method, $endpoint_url, $body = array(), $gateway_environment = '' ) {
+ // If the gateway environment is not set, get it from the options.
+ if ( empty( $gateway_environment ) ) {
+ $gateway_environment = pmpro_getOption( 'gateway_environment' );
+ }
+
+ // If the gateway environment is still not set, default to 'sandbox'.
+ if ( empty( $gateway_environment ) ) {
+ $gateway_environment = 'sandbox';
+ }
+
+ // Get the base URL and credentials for the request.
+ $base_url = ( 'live' === $gateway_environment ) ? 'https://api-m.paypal.com/' : 'https://api-m.sandbox.paypal.com/';
+ $client_id = get_option( 'pmpro_paypalrest_client_id_' . $gateway_environment );
+ $client_secret = get_option( 'pmpro_paypalrest_client_secret_' . $gateway_environment );
+
+ // Build the request.
+ $request_args = array(
+ 'method' => $method,
+ 'headers' => array(
+ 'Authorization' => 'Basic ' . base64_encode( $client_id . ':' . $client_secret ),
+ 'Content-Type' => 'application/json',
+ )
+ );
+ if ( ! empty( $body ) ) {
+ $request_args['body'] = json_encode( $body );
+ }
+
+ // Make the request using wp_remote_request().
+ $response = wp_remote_request( $base_url . $endpoint_url, $request_args );
+
+ // If response is a WP_Error, return the error message.
+ if ( is_wp_error( $response ) ) {
+ return $response->get_error_message();
+ }
+
+ // If the response code is not in the 200 range, return an error message.
+ if ( $response['response']['code'] < 200 || $response['response']['code'] >= 300 ) {
+ return 'Error ' . $response['response']['code'] . ': ' . $response['response']['message'];
+ }
+
+ // Return the response.
+ return json_decode( $response['body'] );
+ }
+
+ /**
+ * Get the webhook URL for the site.
+ *
+ * @since TBD
+ *
+ * @return string The webhook URL.
+ */
+ private static function get_site_webhook_url() {
+ return admin_url( 'admin-ajax.php' ) . '?action=pmpro_paypalrest_webhook';
+ }
+
+ /**
+ * Get the product ID for a specific level. If the product ID does not exist, create it.
+ *
+ * @since TBD
+ *
+ * @param int $level_id The ID of the level to get the product ID for.
+ * @return string|false The product ID or false if the product ID is not found or created.
+ */
+ private static function get_product_id_for_level( $level_id ) {
+ // Get the product ID from the database.
+ $product_id = get_option( 'pmpro_paypalrest_product_id_' . $level_id );
+
+ // If we have a product ID, double check with PayPal to make sure it exists.
+ if ( ! empty( $product_id ) ) {
+ $response = self::send_request(
+ 'GET',
+ 'v1/catalogs/products/' . $product_id
+ );
+
+ // If $response is not an error message, return the product ID.
+ if ( ! is_string( $response ) ) {
+ return $product_id;
+ }
+ }
+
+ // Create a new product.
+ $level = pmpro_getLevel( $level_id );
+ $response = self::send_request(
+ 'POST',
+ 'v1/catalogs/products',
+ array(
+ 'name' => substr( $level->name, 0, 127 ),
+ 'description' => __( 'Created by Paid Memberships Pro.', 'paid-memberships-pro' ),
+ 'type' => 'SERVICE', // TODO: Should this be 'DIGITAL'?
+ )
+ );
+
+ // If $response is an error message, return false.
+ if ( is_string( $response ) ) {
+ return false;
+ }
+
+ // Save the product ID to the database.
+ $product_id = sanitize_text_field( $response->id );
+ update_option( 'pmpro_paypalrest_product_id_' . $level_id, $product_id );
+ return $product_id;
+ }
+
+ /**
+ * Get a plan for a given product, or create a new plan if one does not exist.
+ *
+ * @since TBD
+ *
+ * @param string $product_id The ID of the product to get the plan for.
+ * @param float $setup_fee The setup fee (initial payment) to charge for the plan.
+ * @param float $amount The amount to charge for the plan.
+ * @param string $cycle_period The period of the billing cycle.
+ * @param int $cycle_number The number of billing cycles.
+ * @param float $trial_amount The amount to charge for the trial period.
+ * @param int $trial_limit The number of trial periods (0 for no trial).
+ *
+ * @return string|false The plan ID or false if the plan ID is not found or created.
+ */
+ private static function get_plan_for_product( $product_id, $setup_fee, $amount, $cycle_period, $cycle_number, $trial_amount, $trial_limit, $level_name ) {
+ // Check if we have already created a plan with the same parameters.
+ $page = 1;
+ while ( true ) {
+ // Get a list of plans.
+ $response = self::send_request(
+ 'GET',
+ 'v1/billing/plans/?' . http_build_query(
+ array(
+ 'product_id' => $product_id,
+ 'page_size' => 20, // 20 is the max.
+ 'page' => $page,
+ )
+ )
+ );
+
+ // If we can't get plans, try to create a new one.
+ if ( is_string( $response ) ) {
+ break;
+ }
+
+ // If there are no plans, try to create a new one.
+ $plans_summaries = $response->plans;
+ if ( empty( $plans_summaries ) ) {
+ break;
+ }
+
+ // Check each plan to see if it matches the parameters.
+ foreach ( $plans_summaries as $plans_summary ) {
+ // Get the full plan details.
+ $response = self::send_request(
+ 'GET',
+ 'v1/billing/plans/' . $plans_summary->id
+ );
+
+ // If we can't get the plan details, try the next plan.
+ if ( is_string( $response ) ) {
+ continue;
+ }
+
+ $plan = $response;
+
+ // Check the initial payment.
+ if ( (float) $setup_fee !== (float) $plan->payment_preferences->setup_fee->value ) {
+ continue;
+ }
+
+ // Find the billing cycle where tenure_type is 'REGULAR'.
+ $regular_cycle = null;
+ foreach ( $plan->billing_cycles as $billing_cycle ) {
+ if ( $billing_cycle->tenure_type === 'REGULAR' ) {
+ $regular_cycle = $billing_cycle;
+ break;
+ }
+ }
+ if ( $regular_cycle === null ) {
+ continue;
+ }
+ // Check the cycle information.
+ if (
+ (float) $amount !== (float)$regular_cycle->pricing_scheme->fixed_price->value ||
+ $cycle_period !== $regular_cycle->frequency->interval_unit ||
+ $cycle_number !== $regular_cycle->frequency->interval_count
+ ) {
+ continue;
+ }
+
+ // Check the trial information.
+ if ( ! empty( $trial_limit ) ) {
+ // Find the billing cycle where tenure_type is 'TRIAL'.
+ $trial_cycle = null;
+ foreach ( $plan->billing_cycles as $billing_cycle ) {
+ if ( $billing_cycle->tenure_type === 'TRIAL' ) {
+ $trial_cycle = $billing_cycle;
+ break;
+ }
+ }
+ if ( $trial_cycle === null ) {
+ continue;
+ }
+ if (
+ (float)$trial_amount !== (float)$trial_cycle->pricing_scheme->fixed_price->value ||
+ $trial_limit !== $trial_cycle->frequency->total_cycles ||
+ $cycle_period !== $trial_cycle->frequency->interval_unit ||
+ $cycle_number !== $trial_cycle->frequency->interval_count
+ ) {
+ continue;
+ }
+ }
+
+ // If we made it this far, we found a matching plan.
+ return $plan->id;
+ }
+ $page++;
+ }
+
+ // We couldn't find a matching plan, so create a new one.
+ $billing_cycles = array();
+ $sequence = 1;
+ if ( ! empty( $trial_amount ) ) {
+ $billing_cycles[] = array(
+ 'frequency' => array(
+ 'interval_unit' => $cycle_period,
+ 'interval_count' => $cycle_number,
+ ),
+ 'tenure_type' => 'TRIAL',
+ 'sequence' => $sequence,
+ 'total_cycles' => $trial_limit,
+ 'pricing_scheme' => array(
+ 'fixed_price' => array(
+ 'value' => $trial_amount,
+ 'currency_code' => 'USD',
+ ),
+ ),
+ );
+ $sequence++;
+ }
+ $billing_cycles[] = array(
+ 'frequency' => array(
+ 'interval_unit' => $cycle_period,
+ 'interval_count' => $cycle_number,
+ ),
+ 'tenure_type' => 'REGULAR',
+ 'sequence' => $sequence,
+ 'total_cycles' => 0, // Run indefinitely.
+ 'pricing_scheme' => array(
+ 'fixed_price' => array(
+ 'value' => (string)$amount,
+ 'currency_code' => 'USD',
+ ),
+ ),
+ );
+
+ $response = self::send_request(
+ 'POST',
+ 'v1/billing/plans',
+ array(
+ 'product_id' => $product_id,
+ 'name' => substr( $level_name, 0, 127 ),
+ 'billing_cycles' => $billing_cycles,
+ 'payment_preferences' => array(
+ 'auto_bill_outstanding' => true,
+ 'setup_fee_failure_action' => 'CANCEL',
+ 'setup_fee' => array(
+ 'value' => (string)$setup_fee,
+ 'currency_code' => 'USD',
+ ),
+ ),
+ )
+ );
+
+ if ( is_string( $response ) ) {
+ return false;
+ }
+
+ return $response->id;
+ }
+
+ /**
+ * Get a list of webhook events that are required for Paid Memberships Pro.
+ *
+ * @since TBD
+ *
+ * @return array The list of webhook events.
+ */
+ private static function get_required_webhook_events() {
+ return array(
+ 'CHECKOUT.ORDER.APPROVED',
+ 'BILLING.SUBSCRIPTION.ACTIVATED',
+ 'PAYMENT.SALE.COMPLETED',
+ 'BILLING.SUBSCRIPTION.SUSPENDED',
+ 'BILLING.SUBSCRIPTION.CANCELLED',
+ 'BILLING.SUBSCRIPTION.EXPIRED',
+ 'PAYMENT.CAPTURE.REFUNDED',
+ 'BILLING.SUBSCRIPTION.PAYMENT.FAILED',
+ );
+ }
+
+ /**
+ * Get information about a webhook from PayPal.
+ *
+ * @since TBD
+ *
+ * @param string $webhook_id The ID of the webhook to get information about.
+ * @param string $gateway_environment The environment to use for the request.
+ * @return object|false The webhook object or false if the webhook could not be retrieved.
+ */
+ private static function get_webhook( $webhook_id, $gateway_environment ) {
+ $response = self::send_request(
+ 'GET',
+ 'v1/notifications/webhooks/' . $webhook_id,
+ array(),
+ $gateway_environment
+ );
+
+ if ( is_string( $response ) ) {
+ return false;
+ }
+ return $response;
+ }
+
+ /**
+ * List all webhooks from PayPal.
+ *
+ * @since TBD
+ *
+ * @param string $gateway_environment The environment to use for the request.
+ * @return array|false The list of webhook objects or false if the webhooks could not be retrieved.
+ */
+ private static function get_all_webhooks( $gateway_environment ) {
+ $page = 1;
+ $webhooks = array();
+ while ( true ) {
+ $response = self::send_request(
+ 'GET',
+ 'v1/notifications/webhooks/?' . http_build_query(
+ array(
+ 'page_size' => 20, // 20 is the max.
+ 'page' => $page,
+ )
+ ),
+ array(),
+ $gateway_environment
+ );
+
+ if ( is_string( $response ) ) {
+ return false;
+ }
+
+ $webhooks = array_merge( $webhooks, $response->webhooks );
+ if ( count( $webhooks ) < 20 ) {
+ break;
+ }
+ $page++;
+ }
+
+ return $webhooks;
+ }
+
+ /**
+ * Create a webhook in PayPal.
+ *
+ * If a webhook with the same URL already exists, fix it and use that one instead.
+ *
+ * @since TBD
+ *
+ * @param string $gateway_environment The environment to use for the request.
+ */
+ public static function create_webhook( $gateway_environment ) {
+ // Get the webhook URL.
+ $webhook_url = self::get_site_webhook_url();
+
+ // Get all webhooks from PayPal.
+ $webhooks = self::get_all_webhooks( $gateway_environment );
+
+ // Check if a webhook with the same URL already exists.
+ foreach ( $webhooks as $webhook ) {
+ if ( $webhook->url === $webhook_url ) {
+ // We found a matching webhook. Save the webhook ID.
+ update_option( 'pmpro_paypalrest_webhook_id_' . $gateway_environment, $webhook->id );
+
+ // Make sure the webhook has all the required events.
+ $events = array_map( function( $event ) {
+ return $event->name;
+ }, $webhook->event_types );
+ $required_events = self::get_required_webhook_events();
+ if ( ! empty( array_diff( $required_events, $events ) ) ) {
+ $response = self::send_request(
+ 'PATCH',
+ 'v1/notifications/webhooks/' . $webhook->id,
+ array(
+ array(
+ 'op' => 'replace',
+ 'path' => '/event_types',
+ 'value' => array_map( function( $event ) {
+ return array( 'name' => $event );
+ }, $required_events )
+ ),
+ ),
+ $gateway_environment
+ );
+ }
+
+ // Return to avoid creating a new webhook.
+ return;
+ }
+ }
+
+ // Create a new webhook.
+ $event_types = array_map( function( $event ) {
+ return array( 'name' => $event );
+ }, self::get_required_webhook_events() );
+ $response = self::send_request(
+ 'POST',
+ 'v1/notifications/webhooks',
+ array(
+ 'url' => $webhook_url,
+ 'event_types' => $event_types,
+ ),
+ $gateway_environment
+ );
+
+ // If successful, save the webhook ID.
+ if ( ! is_string( $response ) ) {
+ update_option( 'pmpro_paypalrest_webhook_id_' . $gateway_environment, $response->id );
+ }
+ }
+}
diff --git a/includes/functions.php b/includes/functions.php
index abdd2af52..4b8250235 100644
--- a/includes/functions.php
+++ b/includes/functions.php
@@ -4513,7 +4513,7 @@ function pmpro_allowed_refunds( $order ) {
*
* @param array $allowed_gateways A list of allowed gateways to work with refunds
*/
- $allowed_gateways = apply_filters( 'pmpro_allowed_refunds_gateways', array( 'stripe', 'paypalexpress' ) );
+ $allowed_gateways = apply_filters( 'pmpro_allowed_refunds_gateways', array( 'stripe', 'paypalexpress', 'paypalrest' ) );
//Only apply to these gateways
if( in_array( $order->gateway, $allowed_gateways, true ) ) {
$okay = true;
diff --git a/includes/services.php b/includes/services.php
index d5045250a..8366b98f6 100644
--- a/includes/services.php
+++ b/includes/services.php
@@ -56,6 +56,20 @@ function pmpro_wp_ajax_twocheckout_ins()
}
add_action('wp_ajax_nopriv_twocheckout-ins', 'pmpro_wp_ajax_twocheckout_ins');
add_action('wp_ajax_twocheckout-ins', 'pmpro_wp_ajax_twocheckout_ins');
+function pmpro_wp_ajax_paypalrest_webhook()
+{
+ require_once(dirname(__FILE__) . "/../services/paypalrest-webhook.php");
+ exit;
+}
+add_action('wp_ajax_nopriv_pmpro_paypalrest_webhook', 'pmpro_wp_ajax_paypalrest_webhook');
+add_action('wp_ajax_pmpro_paypalrest_webhook', 'pmpro_wp_ajax_paypalrest_webhook');
+function pmpro_wp_ajax_paypalrest_oauth()
+{
+ require_once(dirname(__FILE__) . "/../services/paypalrest-oauth.php");
+ exit;
+}
+add_action('wp_ajax_nopriv_pmpro_paypalreste_oauth', 'pmpro_wp_ajax_paypalrest_oauth');
+add_action('wp_ajax_pmpro_paypalrest_oauth', 'pmpro_wp_ajax_paypalrest_oauth');
function pmpro_wp_ajax_memberlist_csv()
{
require_once(dirname(__FILE__) . "/../adminpages/memberslist-csv.php");
diff --git a/paid-memberships-pro.php b/paid-memberships-pro.php
index 9c65e7e13..5df187d8a 100644
--- a/paid-memberships-pro.php
+++ b/paid-memberships-pro.php
@@ -139,6 +139,8 @@
require_once( PMPRO_DIR . '/includes/lib/stripe-apple-pay/stripe-apple-pay.php' ); // rewrite rules to set up Apple Pay.
}
+require_once( PMPRO_DIR . '/classes/gateways/class.pmprogateway_paypalrest.php' );
+
// Set up Wisdom tracking.
require_once PMPRO_DIR . '/classes/class-pmpro-wisdom-integration.php';
$wisdom_integration = PMPro_Wisdom_Integration::instance();
diff --git a/services/paypalrest-oauth.php b/services/paypalrest-oauth.php
new file mode 100644
index 000000000..791d6b1f9
--- /dev/null
+++ b/services/paypalrest-oauth.php
@@ -0,0 +1,78 @@
+ 'authorization_code',
+ 'code' => $authCode,
+ 'code_verifier' => $nonce,
+ ),
+ $api_url . '/v1/oauth2/token'
+);
+$access_token_request = wp_remote_post( $access_token_request_url, array(
+ 'headers' => array(
+ // Send the shared ID in Basic Auth (empty client secret after the colon)
+ 'Authorization' => 'Basic ' . base64_encode( $sharedId . ':' ),
+ ),
+) );
+
+// If we didn't get a valid response, bail.
+if ( is_wp_error( $access_token_request ) ) {
+ exit;
+}
+
+// Get the access token from the response.
+$access_token = json_decode( wp_remote_retrieve_body( $access_token_request ) )->access_token;
+
+// Get the seller's REST API credentials from PayPal.
+// TODO: Replace PARTNER-MERCHANT-ID with the actual Partner Merchant ID.
+$credentials_request = wp_remote_get( $api_url . '/v1/customer/partners/UJ97N7FRGGD9C/merchant-integrations/credentials/', array(
+ 'headers' => array(
+ 'Authorization' => 'Bearer ' . $access_token,
+ ),
+) );
+
+// If we didn't get a valid response, bail.
+if ( is_wp_error( $credentials_request ) ) {
+ exit;
+}
+
+// Save the seller's REST API credentials.
+$credentials = json_decode( wp_remote_retrieve_body( $credentials_request ) );
+update_option( 'pmpro_paypalrest_client_id_' . $environment, $credentials->client_id );
+update_option( 'pmpro_paypalrest_client_secret_' . $environment, $credentials->client_secret );
+
+// Set the current gateway to PayPal REST.
+update_option( 'pmpro_gateway', 'paypalrest' );
+
+// Attempt to set up a webhook for the seller.
+PMProGateway_paypalrest::create_webhook( $environment );
diff --git a/services/paypalrest-webhook.php b/services/paypalrest-webhook.php
new file mode 100644
index 000000000..12ef38eae
--- /dev/null
+++ b/services/paypalrest-webhook.php
@@ -0,0 +1,384 @@
+event_type ) ? '' : $body->event_type;
+$resource = empty( $body->resource ) ? '' : $body->resource;
+
+// Get the gateway environment that the webhook is for so that we know which PayPal environment to use.
+$headers = getallheaders();
+$gateway_environment = ( empty( $headers['PAYPAL-CERT-URL'] ) || false !== strpos( $headers['PAYPAL-CERT-URL'], 'sandbox' ) ) ? 'sandbox' : 'live';
+
+// Set up the log string.
+$logstr = '';
+if ( 'sandbox' === $gateway_environment ) {
+ $logstr .= '(SANDBOX) ';
+}
+$logstr .= "Received On: " . date_i18n("m/d/Y H:i:s") . "\n-------------\n";
+
+// Check if we're in development mode. If not, validate the webhook request.
+if ( defined( 'PMPRO_PAYPALREST_DEVELOPMENT_MODE' ) && PMPRO_PAYPALREST_DEVELOPMENT_MODE ) {
+ $validated = true;
+} else {
+ // Validate the webhook request.
+ $webhook_id = get_option( 'pmpro_paypalrest_webhook_id_' . $gateway_environment );
+ $validated = false;
+ $validate_response = PMProGateway_paypalrest::send_request( 'POST', 'v1/notifications/verify-webhook-signature', array(
+ 'auth_algo' => empty( $headers['PAYPAL-AUTH-ALGO'] ) ? '' : $headers['PAYPAL-AUTH-ALGO'],
+ 'cert_url' => empty( $headers['PAYPAL-CERT-URL'] ) ? '' : $headers['PAYPAL-CERT-URL'],
+ 'transmission_id' => empty( $headers['PAYPAL-TRANSMISSION-ID'] ) ? '' : $headers['PAYPAL-TRANSMISSION-ID'],
+ 'transmission_sig' => empty( $headers['PAYPAL-TRANSMISSION-SIG'] ) ? '' : $headers['PAYPAL-TRANSMISSION-SIG'],
+ 'transmission_time' => empty( $headers['PAYPAL-TRANSMISSION-TIME'] ) ? '' : $headers['PAYPAL-TRANSMISSION-TIME'],
+ 'webhook_id' => empty( $webhook_id ) ? '' : $webhook_id,
+ 'webhook_event' => $body,
+ ), $gateway_environment );
+ if ( is_string( $validate_response ) ) {
+ // An error string was returned. Record it.
+ $logstr .= 'Error validating webhook request: ' . $validate_response . "\n";
+ } elseif ( 'SUCCESS' !== $validate_response->verification_status ) {
+ // The webhook request was not validated. Record the error.
+ $logstr .= 'Webhook request not validated.';
+ } else {
+ // The webhook request was validated.
+ $validated = true;
+
+ // Send the 200 OK response early to avoid timeouts.
+ pmpro_send_200_http_response();
+
+ // Update the "last received" option.
+ update_option( 'pmpro_paypalrest_webhook_last_received_' . $gateway_environment, current_time( 'timestamp' ) );
+ }
+}
+
+
+
+if ( ! $validated ) {
+ // The webhook request was not validated. Record the error.
+ $logstr .= 'Webhook request not validated.';
+} else {
+ // The webhook request was validated. Process the event.
+ switch ( $event_type ) {
+ case 'CHECKOUT.ORDER.APPROVED':
+ // Handle one-time payment checkouts.
+ $logstr .= 'Processing one-time payment checkout for PayPal order ID ' . $resource->id . '. ';
+
+ // Make sure that we have an updated order object from PayPal.
+ $response = PMProGateway_paypalrest::send_request( 'GET', 'v2/checkout/orders/' . $resource->id, array(), $gateway_environment );
+ if ( is_string( $response ) ) {
+ // An error string was returned. Record it.
+ $logstr .= 'Error getting updated order data for order ID ' . $resource->id . ': ' . $response;
+ break;
+ } else {
+ // The order data was retrieved successfully. Update $resource with the new data.
+ $resource = $response;
+ }
+
+ // Find the order in PMPro.
+ $order_id = $wpdb->get_var( $wpdb->prepare( "SELECT pmpro_membership_order_id FROM $wpdb->pmpro_membership_ordermeta WHERE meta_key = 'paypalrest_order_id' AND meta_value = %s LIMIT 1", $resource->id ) );
+ if ( empty( $order_id ) ) {
+ $logstr .= "Could not find a PMPro order for PayPal order ID " . $resource->id;
+ } else {
+ $order = new MemberOrder( $order_id );
+ if ( empty( $order ) ) {
+ $logstr .= "Order #" . $order_id . " not found.";
+ }
+ }
+
+ // If we have a PayPal order that still needs to be captured, do so.
+ if ( ! empty( $order ) && 'APPROVED' === $resource->status ) {
+ $response = PMProGateway_paypalrest::send_request( 'POST', 'v2/checkout/orders/' . $resource->id . '/capture', array(), $gateway_environment );
+ if ( is_string( $response ) ) {
+ // An error string was returned. Record it.
+ $logstr .= 'Error capturing payment for order #' . $order->id . ': ' . $response;
+ } else {
+ // The payment was captured successfully. Update $resource with the new data.
+ $resource = $response;
+ }
+ }
+
+ // If we now have a PayPal order in the COMPLETED status, complete the checkout if needed.
+ if ( ! empty( $order ) && 'COMPLETED' === $resource->status ) {
+ $order->payment_transaction_id = $resource->purchase_units[0]->payments->captures[0]->id;
+ $order->saveOrder();
+
+ if ( 'token' === $order->status ) {
+ // The order is still in token status. Complete the checkout.
+ pmpro_pull_checkout_data_from_order( $order );
+ if ( pmpro_complete_async_checkout( $order ) ) {
+ // The checkout was completed successfully.
+ $logstr .= 'Order #' . $order->id . ' completed successfully.';
+ } else {
+ // The checkout failed. Record the error.
+ $logstr .= 'Order #' . $order->id . ' failed to complete.';
+ }
+ } else {
+ // The order is not in token status. Record the error.
+ $logstr .= 'Order #' . $order->id . ' has already been completed.';
+ }
+ }
+ break;
+ case 'BILLING.SUBSCRIPTION.ACTIVATED':
+ // Handle recurring payment checkouts.
+ $logstr .= 'Processing recurring payment checkout for PayPal subscription ID ' . $resource->id . '. ';
+
+ // Find the order in PMPro.
+ $order_search_args = array(
+ 'gateway' => 'paypalrest',
+ 'gateway_environment' => $gateway_environment,
+ 'status' => 'token',
+ 'subscription_transaction_id' => $resource->id,
+ );
+ $order = MemberOrder::get_order( $order_search_args );
+ if ( empty( $order ) ) {
+ // The order was not found. Record the error.
+ $logstr .= 'Token order not found.';
+ } else {
+ // The order was found. Get the payment transaction ID if there was an initial payment. Search between an hour before and after the subscription creation time.
+ $subscription_transactions = PMProGateway_paypalrest::send_request( 'GET', 'v1/billing/subscriptions/' . $resource->id . '/transactions/?' . http_build_query( array( 'start_time' => date( 'c', strtotime( $resource->create_time ) - 3600 ), 'end_time' => date( 'c', strtotime( $resource->create_time ) + 3600 ) ) ), array(), $gateway_environment );
+ if ( is_string( $subscription_transactions ) ) {
+ // An error string was returned. Record it.
+ $logstr .= 'Error getting subscription transactions for subscription ID ' . $resource->id . ': ' . $subscription_transactions;
+ break;
+ }
+
+ if ( ! empty( $subscription_transactions->transactions ) ) {
+ // There is an initial payment. Update the order with the payment transaction ID.
+ $order->payment_transaction_id = $subscription_transactions->transactions[0]->id;
+ $order->saveOrder();
+ }
+
+ // Complete the checkout.
+ pmpro_pull_checkout_data_from_order( $order );
+ if ( pmpro_complete_async_checkout( $order ) ) {
+ // The checkout was completed successfully.
+ $logstr .= 'Order #' . $order->id . ' completed successfully.';
+ } else {
+ // The checkout failed. Record the error.
+ $logstr .= 'Order #' . $order->id . ' failed to complete.';
+ }
+ }
+ break;
+ case 'PAYMENT.SALE.COMPLETED':
+ // Process recurring payments.
+ $logstr .= 'Processing a recurring payment ' . $resource->id . ' for PayPal subscription ID ' . $resource->billing_agreement_id . '. ';
+
+ // First, let's make sure that we don't already have an order with this transaction ID.
+ $existing_order_search_args = array(
+ 'gateway' => 'paypalrest',
+ 'gateway_environment' => $gateway_environment,
+ 'status' => 'success',
+ 'payment_transaction_id' => $resource->id,
+ );
+ $existing_order = MemberOrder::get_order( $existing_order_search_args );
+ if ( ! empty( $existing_order ) ) {
+ // We already have an order with this transaction ID. Record the error.
+ $logstr .= 'Order #' . $existing_order->id . ' already exists.';
+ break;
+ }
+
+ // We also need to be careful not to edit an order that is already going to be processed by the BILLING.SUBSCRIPTION.ACTIVATED event.
+ // We can assume that this is the case when there is token order for the subscription ID.
+ $token_order_search_args = array(
+ 'gateway' => 'paypalrest',
+ 'gateway_environment' => $gateway_environment,
+ 'status' => 'token',
+ 'subscription_transaction_id' => $resource->billing_agreement_id,
+ );
+ $token_order = MemberOrder::get_order( $token_order_search_args );
+ if ( ! empty( $token_order ) ) {
+ // We have a token order for this subscription ID. Record the error.
+ $logstr .= 'Token order #' . $token_order->id . ' exists for subscription ID ' . $resource->billing_agreement_id . '. This order will be processed by BILLING.SUBSCRIPTION.ACTIVATED. ';
+ break;
+ }
+
+ // This payment has not been processed and is not the initial payment. Let's get the PMPro Subscription object for this PayPal subscription.
+ $subscription = PMPro_Subscription::get_subscription_from_subscription_transaction_id( $resource->billing_agreement_id, 'paypalrest', $gateway_environment );
+ if ( empty( $subscription ) ) {
+ // We couldn't find a subscription. Record the error.
+ $logstr .= 'Subscription for subscription ID ' . $resource->billing_agreement_id . ' not found.';
+ break;
+ }
+
+ // We have a subscription. Create a new order.
+ $morder = new MemberOrder();
+ $morder->user_id = $subscription->get_user_id();
+ $morder->membership_id = $subscription->get_membership_level_id();
+ $morder->timestamp = strtotime( $resource->create_time );
+ $morder->payment_transaction_id = $resource->id;
+ $morder->subscription_transaction_id = $resource->billing_agreement_id;
+ $morder->gateway = 'paypalrest';
+ $morder->gateway_environment = $gateway_environment;
+ $morder->status = 'success';
+ $morder->total = $resource->amount->total;
+ $morder->subtotal = empty( $resource->amount->details->subtotal ) ? $resource->amount->total : $resource->amount->details->subtotal;
+ $morder->tax = empty( $resource->amount->details->tax ) ? 0 : $resource->amount->details->tax;
+ $morder->saveOrder();
+
+ $logstr .= 'Order #' . $morder->id . ' created successfully.';
+ break;
+ case 'BILLING.SUBSCRIPTION.SUSPENDED':
+ case 'BILLING.SUBSCRIPTION.CANCELLED':
+ case 'BILLING.SUBSCRIPTION.EXPIRED':
+ // Handle subscription termination.
+ // Note that this will not fire if an admin manually cancels a subscription in PayPal, but will fire if the user cancels their subscription in PayPal.
+ $logstr .= 'Processing subscription termination for PayPal subscription ID ' . $resource->id . '. ';
+ $logstr .= pmpro_handle_subscription_cancellation_at_gateway( $resource->id, 'paypalrest', $gateway_environment );
+ break;
+ case 'PAYMENT.CAPTURE.REFUNDED':
+ // Handle refunds.
+ $logstr .= 'Processing refund ' . $resource->id . '. ';
+
+ if ( ! empty( $resource->capture_id ) ) {
+ // This is using the v1 API version.
+ $logstr .= 'Using v1 API version. ';
+
+ // The transaction ID is given to us.
+ $transaction_id = $resource->capture_id;
+
+ // Also get the refund amount.
+ $refund_amount = abs( (float)$resource->amount->total );
+ } else {
+ // This is using the v2 API version.
+ $logstr .= 'Using v2 API version. ';
+
+ // We need to parse the transaction ID from the "rel"=>"up" link.
+ foreach ( $resource->links as $link ) {
+ if ( 'up' === $link->rel ) {
+ $transaction_id = basename( $link->href );
+ break;
+ }
+ }
+
+ // Also get the refund amount.
+ $refund_amount = abs( (float)$resource->amount->value );
+ }
+
+ // If we don't have a transaction ID, record the error.
+ if ( empty( $transaction_id ) ) {
+ $logstr .= 'Transaction ID not found. ';
+ break;
+ }
+
+ // Log the transaction ID.
+ $logstr .= 'Transaction ID: ' . $transaction_id . '. ';
+
+ // Find the order in PMPro.
+ $order_search_args = array(
+ 'gateway' => 'paypalrest',
+ 'gateway_environment' => $gateway_environment,
+ 'payment_transaction_id' => $transaction_id,
+ );
+ $order = MemberOrder::get_order( $order_search_args );
+
+ // If we don't have an order, record the error.
+ if ( empty( $order ) ) {
+ $logstr .= 'Corresponding order not found. ';
+ break;
+ }
+
+ // Check if the order is already refunded.
+ if ( 'refunded' === $order->status ) {
+ $logstr .= 'Order #' . $order->id . ' is already refunded. ';
+ break;
+ }
+
+ // If the order isn't in success status, record the error.
+ if ( 'success' !== $order->status ) {
+ $logstr .= 'Order #' . $order->id . ' is not in success status. ';
+ break;
+ }
+
+ // Make a note if the refund is partial.
+ if ( $refund_amount < $order->total ) {
+ $logstr .= 'Partial refund for $' . $refund_amount . '. ';
+ $order->notes .= 'Webhook: Partial refund for $' . $refund_amount . '. ';
+ }
+
+ // Mark the order as refunded.
+ $order->status = 'refunded';
+ $order->saveOrder();
+ $logstr .= 'Order #' . $order->id . ' marked as refunded. ';
+
+ // Get the user for the order.
+ $user = get_userdata( $order->user_id );
+
+ // If we don't have a user, record the error.
+ if ( empty( $user ) ) {
+ $logstr .= 'User not found. ';
+ break;
+ }
+
+ // Send emails to the user and admin.
+ $pmproemail = new PMProEmail();
+ $pmproemail->sendRefundedEmail( $user, $order );
+ $pmproemail = new PMProEmail();
+ $pmproemail->sendRefundedAdminEmail( $user, $order );
+ break;
+ case 'BILLING.SUBSCRIPTION.PAYMENT.FAILED':
+ // Handle failed payments.
+ $logstr .= 'Processing failed payment for PayPal subscription ID ' . $resource->id . '. ';
+
+ // Get the PMPro Subscription object for this PayPal subscription.
+ $subscription = PMPro_Subscription::get_subscription_from_subscription_transaction_id( $resource->id, 'paypalrest', $gateway_environment );
+
+ // If we couldn't find a subscription, record the error.
+ if ( empty( $subscription ) ) {
+ $logstr .= 'Subscription for subscription ID ' . $resource->id . ' not found.';
+ break;
+ }
+
+ // Get the user for the subscription.
+ $user = get_userdata( $subscription->get_user_id() );
+
+ // If we don't have a user, record the error.
+ if ( empty( $user ) ) {
+ $logstr .= 'User not found.';
+ break;
+ }
+
+ // Create a fake MemberOrder object to pass to our payment failed email.
+ $order = new MemberOrder();
+ $order->user_id = $subscription->get_user_id();
+ $order->membership_id = $subscription->get_membership_level_id();
+
+ // Send emails to the user and admin.
+ $pmproemail = new PMProEmail();
+ $pmproemail->sendBillingFailureEmail( $user, $order );
+ $pmproemail = new PMProEmail();
+ $pmproemail->sendBillingFailureAdminEmail( get_bloginfo( 'admin_email' ), $order );
+ break;
+ default:
+ // Handle other events.
+ $logstr .= 'Unknown event type: ' . $event_type;
+ break;
+ }
+}
+
+// Process the log string.
+echo esc_html( $logstr );
+if ( defined( 'PMPRO_PAYPALREST_WEBHOOK_DEBUG' ) && PMPRO_PAYPALREST_WEBHOOK_DEBUG === 'log' ) {
+ // Log to file.
+ $logfile = apply_filters( 'pmpro_paypalrest_webhook_logfile', dirname( __FILE__ ) . "/../logs/paypalrest-webhook.txt" );
+ $loghandle = fopen( $logfile, "a+" );
+ fwrite( $loghandle, $logstr . "\n" );
+ fclose( $loghandle );
+} elseif( defined('PMPRO_PAYPALREST_WEBHOOK_DEBUG' ) && false !== PMPRO_PAYPALREST_WEBHOOK_DEBUG ) {
+ // Send log to email.
+ if(strpos(PMPRO_PAYPALREST_WEBHOOK_DEBUG, "@"))
+ $log_email = PMPRO_PAYPALREST_WEBHOOK_DEBUG; // Constant defines a specific email address.
+ else
+ $log_email = get_option("admin_email");
+
+ wp_mail( $log_email, get_option( "blogname" ) . " Stripe Webhook Log", nl2br( esc_html( $logstr ) ) );
+}
+
+exit;
\ No newline at end of file