From 8f93926135bf5efef3b69f609f97173768d911cc Mon Sep 17 00:00:00 2001 From: David Parker Date: Thu, 5 Dec 2024 14:55:35 -0500 Subject: [PATCH 01/17] Initial commit for PayPal REST --- .../class.pmprogateway_paypalrest.php | 704 ++++++++++++++++++ includes/services.php | 7 + paid-memberships-pro.php | 2 + services/paypalrest-webhook.php | 272 +++++++ 4 files changed, 985 insertions(+) create mode 100644 classes/gateways/class.pmprogateway_paypalrest.php create mode 100644 services/paypalrest-webhook.php diff --git a/classes/gateways/class.pmprogateway_paypalrest.php b/classes/gateways/class.pmprogateway_paypalrest.php new file mode 100644 index 000000000..53261664c --- /dev/null +++ b/classes/gateways/class.pmprogateway_paypalrest.php @@ -0,0 +1,704 @@ + true, + 'payment_method_updates' => false, + ); + + if ( empty( $supports[$feature] ) ) { + return false; + } + + return $supports[$feature]; + } + + /** + * 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 ) { + $gateways['paypalrest'] = 'PayPal REST'; + return $gateways; + } + + /** + * 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 ) { + $options = array_merge( + $options, + array( + 'paypalrest_client_id', + 'paypalrest_client_secret', + 'gateway_environment', + 'currency', + 'tax_state', + 'tax_rate', + ) + ); + return $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 ) { + ?> + style="display: none;"> + +

+ + + style="display: none;"> + + + + + + + + style="display: none;"> + + + + + + + + style="display: none;"> + +
+

+ + + style="display: none;"> + + + + +

+ + + + 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', json_decode( $response['body'] )->id ); + + // Find the payer action link and redirect the user to it. + $links = json_decode( $response['body'] )->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 ); + 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 = json_decode( $response['body'] )->id; + $order->saveOrder(); + + // Find the approve link and redirect the user to it. + $links = json_decode( $response['body'] )->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; + } + + /** + * 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 = json_decode( $response['body'] ); + $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 = json_decode( $response['body'] ); + 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 ); + } + + /** + * 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 array|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' ); + } + + // TODO: Double-check the live URL. + $base_url = ( 'live' === $gateway_environment ) ? 'https://api-m.paypal.com/' : 'https://api-m.sandbox.paypal.com/'; + $client_id = get_option( 'pmpro_paypalrest_client_id' ); + $client_secret = get_option( 'pmpro_paypalrest_client_secret' ); + + // 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 $response; + } + + /** + * 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( json_decode( $response['body'] )->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 ) { + // 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 = json_decode( $response['body'] )->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 = json_decode( $response['body'] ); + + // 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' => 'Test Plan ' . substr( time(), -4 ), + '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 json_decode( $response['body'] )->id; + } +} diff --git a/includes/services.php b/includes/services.php index d5045250a..d3ee2d79b 100644 --- a/includes/services.php +++ b/includes/services.php @@ -56,6 +56,13 @@ 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_memberlist_csv() { require_once(dirname(__FILE__) . "/../adminpages/memberslist-csv.php"); diff --git a/paid-memberships-pro.php b/paid-memberships-pro.php index 38efe602f..b6d29cfe5 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-webhook.php b/services/paypalrest-webhook.php new file mode 100644 index 000000000..8fb7d43b5 --- /dev/null +++ b/services/paypalrest-webhook.php @@ -0,0 +1,272 @@ +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. + $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' => 'YOUR_WEBHOOK_ID', // TODO: Get the webhook ID from the database. + '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' !== json_decode( $validate_response['body'] )->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(); + } +} + + + +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 = json_decode( $response['body'] ); + } + + // 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', $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 = json_decode( $response['body'] ); + } + } + + // 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_transsactions = 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_transsactions ) ) { + // An error string was returned. Record it. + $logstr .= 'Error getting subscription transactions for subscription ID ' . $resource->id . ': ' . $subscription_transsactions; + break; + } else { + // The subscription transactions were retrieved successfully. Update $resource with the new data. + $subscription_transsactions = json_decode( $subscription_transsactions['body'] ); + + // If there is an initial payment, update the order with the payment transaction ID. + if ( ! empty( $subscription_transsactions->transactions ) ) { + $order->payment_transaction_id = $subscription_transsactions->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 payent ' . $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. + if ( empty( $existing_order ) ) { + $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; + } + } + + // If we don't have an existing order and this isn't an initial payment, let's get the PMPro Subscription object for this PayPal subscription. + if ( empty( $existing_order ) && ! $is_initial_payent ) { + $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.'; + } + } + + // If we have a subscription, we can create a new order. + if ( ! empty( $subscription) ) { + $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. + $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. + // TODO: Is this the correct event type for refunds? + break; + case 'BILLING.SUBSCRIPTION.PAYMENT.FAILED': + // Handle denied payments. + // TODO: Implement this. + 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 ); + 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 From d089e43140cc4191348ac15c4b7e89ed591a58d3 Mon Sep 17 00:00:00 2001 From: David Parker Date: Tue, 10 Dec 2024 14:16:14 -0500 Subject: [PATCH 02/17] Added OAuth flow for PayPal REST --- .../class.pmprogateway_paypalrest.php | 232 +++++++++++++++--- includes/services.php | 7 + services/paypalrest-oauth.php | 72 ++++++ 3 files changed, 275 insertions(+), 36 deletions(-) create mode 100644 services/paypalrest-oauth.php diff --git a/classes/gateways/class.pmprogateway_paypalrest.php b/classes/gateways/class.pmprogateway_paypalrest.php index 53261664c..58434739e 100644 --- a/classes/gateways/class.pmprogateway_paypalrest.php +++ b/classes/gateways/class.pmprogateway_paypalrest.php @@ -26,6 +26,7 @@ public static function init() { // Add fields to the payment settings page. add_filter( 'pmpro_payment_options', array( 'PMProGateway_paypalrest', 'pmpro_payment_options' ) ); add_filter( 'pmpro_payment_option_fields', array( 'PMProGateway_paypalrest', 'pmpro_payment_option_fields' ), 10, 2 ); + add_action( 'pmpro_after_saved_payment_options', array( 'PMProGateway_paypalrest', 'pmpro_after_saved_payment_options' ) ); // Checkout filters. $gateway = pmpro_getGateway(); @@ -82,8 +83,6 @@ public static function pmpro_payment_options( $options ) { $options = array_merge( $options, array( - 'paypalrest_client_id', - 'paypalrest_client_secret', 'gateway_environment', 'currency', 'tax_state', @@ -102,43 +101,133 @@ public static function pmpro_payment_options( $options ) { * @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;"> - - - - - - - - style="display: none;"> - - - - - - - - style="display: none;"> + + + style="display: none;">
-

- - - style="display: none;"> - - - - -

+

+ style="display: none;"> + + + + + $nonce, + 'environment' => $environment, + ), admin_url( '?pmpro_get_paypalrest_signup_link=pmpro_get_paypalrest_signup_link' ) ); // TODO: Change this to the actual URL. + $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;"> + + + + +

+ + + id; } } + +// Everything below here is sample code for generating OAuth connection urls and should be deleted once +// the Stranger Studios server is set up. +if ( ! empty( $_REQUEST['pmpro_get_paypalrest_signup_link'] ) ) { + $nonce = empty($_REQUEST['nonce']) ? "" : $_REQUEST['nonce']; + $environment = empty($_REQUEST['environment']) ? "sandbox" : $_REQUEST['environment']; + + // TODO: Get the correct client and secret IDs for the Stranger Studios platform account. + $platform_client_id = defined( 'PMPRO_PAYPALREST_PLATFORM_CLIENT_ID' ) ? PMPRO_PAYPALREST_PLATFORM_CLIENT_ID : ''; + $platform_client_secret = defined( 'PMPRO_PAYPALREST_PLATFORM_CLIENT_SECRET' ) ? PMPRO_PAYPALREST_PLATFORM_CLIENT_SECRET : ''; + + // Get the OAuth link to send the user to. + $ch = curl_init( ( $environment === 'sandbox' ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com' ) . '/v2/customer/partner-referrals' ); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + "Content-Type: application/json", + "Authorization: Basic " . base64_encode( $platform_client_id . ":" . $platform_client_secret ) + ]); + $data = [ + "operations" => [ + [ + "operation" => "API_INTEGRATION", + "api_integration_preference" => [ + "rest_api_integration" => [ + "integration_method" => "PAYPAL", + "integration_type" => "FIRST_PARTY", + "first_party_details" => [ + "features" => ["PAYMENT", "REFUND"], + "seller_nonce" => $nonce + ] + ] + ] + ] + ], + "products" => ["EXPRESS_CHECKOUT"], + "legal_consents" => [ + [ + "type" => "SHARE_DATA_CONSENT", + "granted" => true + ] + ] + ]; + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); + + // Execute cURL request and get the response + $response = curl_exec($ch); + + // Close cURL + curl_close($ch); + + // If successful, return the link with rel action_url + if ($response) { + $response = json_decode($response, true); + $links = $response['links']; + foreach ($links as $link) { + if ($link['rel'] === 'action_url') { + // Redirect to the PayPal Partner Referrals API link + header('Location: ' . $link['href']); + exit; + } + } + } else { + // TODO: Handle error + } +} diff --git a/includes/services.php b/includes/services.php index d3ee2d79b..8366b98f6 100644 --- a/includes/services.php +++ b/includes/services.php @@ -63,6 +63,13 @@ function pmpro_wp_ajax_paypalrest_webhook() } 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/services/paypalrest-oauth.php b/services/paypalrest-oauth.php new file mode 100644 index 000000000..cf0f1f7d3 --- /dev/null +++ b/services/paypalrest-oauth.php @@ -0,0 +1,72 @@ + '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 ); From db6492ad3ce25f2e078e720653a40d3edb29af43 Mon Sep 17 00:00:00 2001 From: David Parker Date: Tue, 10 Dec 2024 14:42:55 -0500 Subject: [PATCH 03/17] Allow cancelling subs in PayPal REST --- .../class.pmprogateway_paypalrest.php | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/classes/gateways/class.pmprogateway_paypalrest.php b/classes/gateways/class.pmprogateway_paypalrest.php index 58434739e..60d568005 100644 --- a/classes/gateways/class.pmprogateway_paypalrest.php +++ b/classes/gateways/class.pmprogateway_paypalrest.php @@ -441,6 +441,28 @@ function process( &$order ) { 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. * @@ -532,8 +554,8 @@ public static function send_request( $method, $endpoint_url, $body = array(), $g // 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 ); + $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( From 32c8f5f47baaf525275a98d9bce4ef91b33d0fe5 Mon Sep 17 00:00:00 2001 From: David Parker Date: Tue, 10 Dec 2024 14:48:04 -0500 Subject: [PATCH 04/17] Fixing currency and tax settings for PayPal REST --- .../class.pmprogateway_paypalrest.php | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/classes/gateways/class.pmprogateway_paypalrest.php b/classes/gateways/class.pmprogateway_paypalrest.php index 60d568005..5ecff099f 100644 --- a/classes/gateways/class.pmprogateway_paypalrest.php +++ b/classes/gateways/class.pmprogateway_paypalrest.php @@ -71,6 +71,23 @@ public static function pmpro_gateways( $gateways ) { 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 1.8 + */ + public static function getGatewayOptions() { + $options = array( + 'gateway_environment', + 'currency', + 'tax_state', + 'tax_rate', + ); + + return $options; + } + /** * Set payment options for payment settings page. * @@ -80,16 +97,10 @@ public static function pmpro_gateways( $gateways ) { * @return array The updated list of payment options. */ public static function pmpro_payment_options( $options ) { - $options = array_merge( - $options, - array( - 'gateway_environment', - 'currency', - 'tax_state', - 'tax_rate', - ) - ); - return $options; + // Get the list of gateway options. + $paypalrest_options = self::getGatewayOptions(); + + return array_merge( $options, $paypalrest_options ); } /** From 85a73536f15fe83e6c089211f79d1679adb358c6 Mon Sep 17 00:00:00 2001 From: David Parker Date: Tue, 10 Dec 2024 14:48:27 -0500 Subject: [PATCH 05/17] Fixing version comment --- classes/gateways/class.pmprogateway_paypalrest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/gateways/class.pmprogateway_paypalrest.php b/classes/gateways/class.pmprogateway_paypalrest.php index 5ecff099f..0726afa34 100644 --- a/classes/gateways/class.pmprogateway_paypalrest.php +++ b/classes/gateways/class.pmprogateway_paypalrest.php @@ -75,7 +75,7 @@ public static function pmpro_gateways( $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 1.8 + * @since TBD */ public static function getGatewayOptions() { $options = array( From 6d07ea043d1d26c197d7ef6761c60e78845bea98 Mon Sep 17 00:00:00 2001 From: David Parker Date: Wed, 11 Dec 2024 11:36:06 -0500 Subject: [PATCH 06/17] Adding webhook management for PayPal REST --- .../class.pmprogateway_paypalrest.php | 250 +++++++++++++++++- services/paypalrest-webhook.php | 6 +- 2 files changed, 253 insertions(+), 3 deletions(-) diff --git a/classes/gateways/class.pmprogateway_paypalrest.php b/classes/gateways/class.pmprogateway_paypalrest.php index 0726afa34..918584282 100644 --- a/classes/gateways/class.pmprogateway_paypalrest.php +++ b/classes/gateways/class.pmprogateway_paypalrest.php @@ -214,18 +214,105 @@ function (authCode, share 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' ); + } + } + } + ?> + + + + : + + + +

:

+ + +

+ +

+ + + + + + : + + + + + 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 json_decode( $response['body'] ); + } + + /** + * 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, json_decode( $response['body'] )->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. + */ + private 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, json_decode( $response['body'] )->id ); + } + } } + // Everything below here is sample code for generating OAuth connection urls and should be deleted once // the Stranger Studios server is set up. if ( ! empty( $_REQUEST['pmpro_get_paypalrest_signup_link'] ) ) { diff --git a/services/paypalrest-webhook.php b/services/paypalrest-webhook.php index 8fb7d43b5..63e573862 100644 --- a/services/paypalrest-webhook.php +++ b/services/paypalrest-webhook.php @@ -30,6 +30,7 @@ $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'], @@ -37,7 +38,7 @@ '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' => 'YOUR_WEBHOOK_ID', // TODO: Get the webhook ID from the database. + 'webhook_id' => empty( $webhook_id ) ? '' : $webhook_id, 'webhook_event' => $body, ), $gateway_environment ); if ( is_string( $validate_response ) ) { @@ -52,6 +53,9 @@ // 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' ) ); } } From b5351da62aad6a2cbeebaf7f2bcb8a30abfcec05 Mon Sep 17 00:00:00 2001 From: David Parker Date: Wed, 11 Dec 2024 12:04:08 -0500 Subject: [PATCH 07/17] Simplifying response from PayPal REST send_request --- .../class.pmprogateway_paypalrest.php | 30 +++++++++---------- services/paypalrest-webhook.php | 4 +-- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/classes/gateways/class.pmprogateway_paypalrest.php b/classes/gateways/class.pmprogateway_paypalrest.php index 918584282..92c93d972 100644 --- a/classes/gateways/class.pmprogateway_paypalrest.php +++ b/classes/gateways/class.pmprogateway_paypalrest.php @@ -451,10 +451,10 @@ function process( &$order ) { // 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', json_decode( $response['body'] )->id ); + update_pmpro_membership_order_meta( $order->id, 'paypalrest_order_id', $response->id ); // Find the payer action link and redirect the user to it. - $links = json_decode( $response['body'] )->links; + $links = $response->links; foreach ( $links as $link ) { if ( $link->rel === 'payer-action' ) { wp_redirect( $link->href ); @@ -517,11 +517,11 @@ function process( &$order ) { // 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 = json_decode( $response['body'] )->id; + $order->subscription_transaction_id = $response->id; $order->saveOrder(); // Find the approve link and redirect the user to it. - $links = json_decode( $response['body'] )->links; + $links = $response->links; foreach ( $links as $link ) { if ( $link->rel === 'approve' ) { wp_redirect( $link->href ); @@ -590,7 +590,7 @@ public function update_subscription_info( $subscription ) { } // Update the subscription with the new data. - $paypal_subscription = json_decode( $response['body'] ); + $paypal_subscription = $response; $update_array = array( 'startdate' => date( 'Y-m-d H:i:s', strtotime( $paypal_subscription->create_time ) ), ); @@ -614,7 +614,7 @@ public function update_subscription_info( $subscription ) { return $response; } - $paypal_plan = json_decode( $response['body'] ); + $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; @@ -645,7 +645,7 @@ public function update_subscription_info( $subscription ) { * @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 array|string The response from the request or an error message. + * @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. @@ -689,7 +689,7 @@ public static function send_request( $method, $endpoint_url, $body = array(), $g } // Return the response. - return $response; + return json_decode( $response['body'] ); } /** @@ -746,7 +746,7 @@ private static function get_product_id_for_level( $level_id ) { } // Save the product ID to the database. - $product_id = sanitize_text_field( json_decode( $response['body'] )->id ); + $product_id = sanitize_text_field( $response->id ); update_option( 'pmpro_paypalrest_product_id_' . $level_id, $product_id ); return $product_id; } @@ -788,7 +788,7 @@ private static function get_plan_for_product( $product_id, $setup_fee, $amount, } // If there are no plans, try to create a new one. - $plans_summaries = json_decode( $response['body'] )->plans; + $plans_summaries = $response->plans; if ( empty( $plans_summaries ) ) { break; } @@ -806,7 +806,7 @@ private static function get_plan_for_product( $product_id, $setup_fee, $amount, continue; } - $plan = json_decode( $response['body'] ); + $plan = $response; // Check the initial payment. if ( (float) $setup_fee !== (float) $plan->payment_preferences->setup_fee->value ) { @@ -921,7 +921,7 @@ private static function get_plan_for_product( $product_id, $setup_fee, $amount, return false; } - return json_decode( $response['body'] )->id; + return $response->id; } /** @@ -964,7 +964,7 @@ private static function get_webhook( $webhook_id, $gateway_environment ) { if ( is_string( $response ) ) { return false; } - return json_decode( $response['body'] ); + return $response; } /** @@ -995,7 +995,7 @@ private static function get_all_webhooks( $gateway_environment ) { return false; } - $webhooks = array_merge( $webhooks, json_decode( $response['body'] )->webhooks ); + $webhooks = array_merge( $webhooks, $response->webhooks ); if ( count( $webhooks ) < 20 ) { break; } @@ -1070,7 +1070,7 @@ private static function create_webhook( $gateway_environment ) { // If successful, save the webhook ID. if ( ! is_string( $response ) ) { - update_option( 'pmpro_paypalrest_webhook_id_' . $gateway_environment, json_decode( $response['body'] )->id ); + update_option( 'pmpro_paypalrest_webhook_id_' . $gateway_environment, $response->id ); } } } diff --git a/services/paypalrest-webhook.php b/services/paypalrest-webhook.php index 63e573862..858878446 100644 --- a/services/paypalrest-webhook.php +++ b/services/paypalrest-webhook.php @@ -79,7 +79,7 @@ break; } else { // The order data was retrieved successfully. Update $resource with the new data. - $resource = json_decode( $response['body'] ); + $resource = $response; } // Find the order in PMPro. @@ -101,7 +101,7 @@ $logstr .= 'Error capturing payment for order #' . $order->id . ': ' . $response; } else { // The payment was captured successfully. Update $resource with the new data. - $resource = json_decode( $response['body'] ); + $resource = $response; } } From 202f79cdf758e5f8c98f799fa828d26531fb8ee6 Mon Sep 17 00:00:00 2001 From: David Parker Date: Wed, 11 Dec 2024 12:12:07 -0500 Subject: [PATCH 08/17] Replacing placeholder plan name --- classes/gateways/class.pmprogateway_paypalrest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/classes/gateways/class.pmprogateway_paypalrest.php b/classes/gateways/class.pmprogateway_paypalrest.php index 92c93d972..c5ad3d53c 100644 --- a/classes/gateways/class.pmprogateway_paypalrest.php +++ b/classes/gateways/class.pmprogateway_paypalrest.php @@ -489,7 +489,7 @@ function process( &$order ) { $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 ); + $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' ); @@ -766,7 +766,7 @@ private static function get_product_id_for_level( $level_id ) { * * @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 ) { + 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 ) { @@ -904,7 +904,7 @@ private static function get_plan_for_product( $product_id, $setup_fee, $amount, 'v1/billing/plans', array( 'product_id' => $product_id, - 'name' => 'Test Plan ' . substr( time(), -4 ), + 'name' => substr( $level_name, 0, 127 ), 'billing_cycles' => $billing_cycles, 'payment_preferences' => array( 'auto_bill_outstanding' => true, From 234f6a72481f1fafb012dcf9e02b0a150b4cba9f Mon Sep 17 00:00:00 2001 From: David Parker Date: Wed, 11 Dec 2024 13:36:55 -0500 Subject: [PATCH 09/17] PayPal REST support refunds --- .../class.pmprogateway_paypalrest.php | 46 +++++++++++++++++++ includes/functions.php | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/classes/gateways/class.pmprogateway_paypalrest.php b/classes/gateways/class.pmprogateway_paypalrest.php index c5ad3d53c..7407b0028 100644 --- a/classes/gateways/class.pmprogateway_paypalrest.php +++ b/classes/gateways/class.pmprogateway_paypalrest.php @@ -28,6 +28,9 @@ public static function init() { add_filter( 'pmpro_payment_option_fields', array( 'PMProGateway_paypalrest', 'pmpro_payment_option_fields' ), 10, 2 ); add_action( 'pmpro_after_saved_payment_options', array( 'PMProGateway_paypalrest', 'pmpro_after_saved_payment_options' ) ); + // Allow processing refunds. + add_filter( 'pmpro_process_refund_paypalrest', array( 'PMProGateway_paypalrest', 'process_refund' ), 10, 2 ); + // Checkout filters. $gateway = pmpro_getGateway(); if ( $gateway === 'paypalrest' ) { @@ -635,6 +638,49 @@ public function update_subscription_info( $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; + + } + /** * Send a request to the PayPal API. * diff --git a/includes/functions.php b/includes/functions.php index 1da6088ee..af991487c 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -4507,7 +4507,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; From 71d617c2c285960e95ef895633a4e5c92ef9a146 Mon Sep 17 00:00:00 2001 From: David Parker Date: Wed, 11 Dec 2024 14:58:40 -0500 Subject: [PATCH 10/17] Webhook handlers for failed payments and refunds --- services/paypalrest-webhook.php | 121 +++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 3 deletions(-) diff --git a/services/paypalrest-webhook.php b/services/paypalrest-webhook.php index 858878446..d947030b1 100644 --- a/services/paypalrest-webhook.php +++ b/services/paypalrest-webhook.php @@ -242,11 +242,126 @@ break; case 'PAYMENT.CAPTURE.REFUNDED': // Handle refunds. - // TODO: Is this the correct event type for 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 denied payments. - // TODO: Implement this. + // 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. From d442350bfa1cf8c35139317fb89cdbbfee6a0b5d Mon Sep 17 00:00:00 2001 From: David Parker Date: Thu, 12 Dec 2024 13:15:34 -0500 Subject: [PATCH 11/17] Using connect.paidmembershipspro.com/paypal --- .../class.pmprogateway_paypalrest.php | 69 +------------------ 1 file changed, 1 insertion(+), 68 deletions(-) diff --git a/classes/gateways/class.pmprogateway_paypalrest.php b/classes/gateways/class.pmprogateway_paypalrest.php index 7407b0028..2fbcdc6d6 100644 --- a/classes/gateways/class.pmprogateway_paypalrest.php +++ b/classes/gateways/class.pmprogateway_paypalrest.php @@ -160,7 +160,7 @@ private static function show_environment_fields( $environment, $display ) { $oauth_url = add_query_arg( array( 'nonce' => $nonce, 'environment' => $environment, - ), admin_url( '?pmpro_get_paypalrest_signup_link=pmpro_get_paypalrest_signup_link' ) ); // TODO: Change this to the actual URL. + ), 'https://connect.paidmembershipspro.com/paypal/v1' ); $paypal_script_callback_name = 'pmpro_paypalrest_oauth_callback_' . $environment; ?>