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