From 25ede3f1f113c048f7c009bb7e328cf3cfb6b86f Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 1 Apr 2026 09:48:37 -0600 Subject: [PATCH] t523: feat(paypal): PayPal PPCP integration review compliance - Update disconnect dialog to use PayPal's required disclaimer text - Show error banners when payments_receivable=false or email_confirmed=false after OAuth - Block paypal-rest from active gateways when merchant status is invalid - Add payee.merchant_id to purchase_units when connected via OAuth - Log PayPal-Debug-Id header from every API response Closes #725 --- inc/gateways/class-paypal-oauth-handler.php | 27 ++++-- inc/gateways/class-paypal-rest-gateway.php | 94 ++++++++++++++++----- 2 files changed, 90 insertions(+), 31 deletions(-) diff --git a/inc/gateways/class-paypal-oauth-handler.php b/inc/gateways/class-paypal-oauth-handler.php index 16df60f5f..f41cca792 100644 --- a/inc/gateways/class-paypal-oauth-handler.php +++ b/inc/gateways/class-paypal-oauth-handler.php @@ -260,23 +260,34 @@ public function handle_oauth_return(): void { wu_save_setting('paypal_rest_connection_mode', $mode_prefix); // Store additional status info if available - if (! empty($merchant_status['paymentsReceivable'])) { - wu_save_setting("paypal_rest_{$mode_prefix}_payments_receivable", $merchant_status['paymentsReceivable']); - } + $payments_receivable = isset($merchant_status['paymentsReceivable']) ? (bool) $merchant_status['paymentsReceivable'] : true; + $email_confirmed = isset($merchant_status['emailConfirmed']) ? (bool) $merchant_status['emailConfirmed'] : true; - if (! empty($merchant_status['emailConfirmed'])) { - wu_save_setting("paypal_rest_{$mode_prefix}_email_confirmed", $merchant_status['emailConfirmed']); - } + wu_save_setting("paypal_rest_{$mode_prefix}_payments_receivable", $payments_receivable); + wu_save_setting("paypal_rest_{$mode_prefix}_email_confirmed", $email_confirmed); // Clean up the tracking transient delete_site_transient('wu_paypal_onboarding_' . $tracking_id); - wu_log_add('paypal', sprintf('PayPal OAuth completed. Merchant ID: %s, Mode: %s', $merchant_id, $mode_prefix)); + wu_log_add('paypal', sprintf('PayPal OAuth completed. Merchant ID: %s, Mode: %s, payments_receivable: %s, email_confirmed: %s', $merchant_id, $mode_prefix, $payments_receivable ? 'true' : 'false', $email_confirmed ? 'true' : 'false')); // Automatically install webhooks for the connected account $this->install_webhook_after_oauth($mode_prefix); - $this->add_oauth_notice('success', __('PayPal account connected successfully!', 'ultimate-multisite')); + // Show required PayPal error messages when merchant status is incomplete + if (! $payments_receivable) { + $this->add_oauth_notice( + 'error', + __('Your PayPal account is not yet able to receive payments. Please complete your PayPal account setup and try connecting again.', 'ultimate-multisite') + ); + } elseif (! $email_confirmed) { + $this->add_oauth_notice( + 'error', + __('Your PayPal account email address is not confirmed. Please confirm your email with PayPal and try connecting again.', 'ultimate-multisite') + ); + } else { + $this->add_oauth_notice('success', __('PayPal account connected successfully!', 'ultimate-multisite')); + } // Redirect to remove query parameters wp_safe_redirect( diff --git a/inc/gateways/class-paypal-rest-gateway.php b/inc/gateways/class-paypal-rest-gateway.php index e9c482e8f..3b81ac7d3 100644 --- a/inc/gateways/class-paypal-rest-gateway.php +++ b/inc/gateways/class-paypal-rest-gateway.php @@ -193,6 +193,9 @@ public function hooks(): void { // Hide PayPal from checkout when currency is not supported add_filter('wu_get_active_gateways', [$this, 'maybe_remove_for_unsupported_currency']); + // Hide PayPal from checkout when merchant cannot receive payments + add_filter('wu_get_active_gateways', [$this, 'maybe_remove_for_invalid_merchant_status']); + // Register PayPal checkout scripts (button branding) add_action('wu_checkout_scripts', [$this, 'register_scripts']); @@ -246,6 +249,37 @@ public function maybe_remove_for_unsupported_currency(array $gateways): array { return $gateways; } + /** + * Removes PayPal from the active gateways list when the merchant cannot receive payments. + * + * PayPal requires that merchants with `payments_receivable=false` or + * `email_confirmed=false` are blocked from processing payments until + * their account setup is complete. + * + * Hooked to 'wu_get_active_gateways'. + * + * @since 2.0.0 + * @param array $gateways The registered active gateways. + * @return array + */ + public function maybe_remove_for_invalid_merchant_status(array $gateways): array { + + // Only applies when connected via OAuth + if (empty($this->merchant_id)) { + return $gateways; + } + + $mode_prefix = $this->test_mode ? 'sandbox' : 'live'; + $payments_receivable = wu_get_setting("paypal_rest_{$mode_prefix}_payments_receivable", true); + $email_confirmed = wu_get_setting("paypal_rest_{$mode_prefix}_email_confirmed", true); + + if (! $payments_receivable || ! $email_confirmed) { + unset($gateways['paypal-rest']); + } + + return $gateways; + } + /** * Returns a branded HTML label for the PayPal option in the checkout gateway selector. * @@ -678,6 +712,12 @@ protected function api_request(string $endpoint, array $data = [], string $metho return $response; } + // Log PayPal-Debug-Id for every response to aid support and review submissions. + $debug_id = wp_remote_retrieve_header($response, 'paypal-debug-id'); + if ($debug_id) { + $this->log(sprintf('PayPal-Debug-Id: %s [%s %s]', $debug_id, $method, $endpoint)); + } + $body = json_decode(wp_remote_retrieve_body($response), true); $code = wp_remote_retrieve_response_code($response); @@ -1010,6 +1050,35 @@ protected function create_order($payment, $membership, $customer, $cart, $type): $currency = $this->get_payment_currency_code($payment); $description = $this->get_subscription_description($cart); + $purchase_unit = [ + 'reference_id' => $payment->get_hash(), + 'description' => substr($description, 0, 127), + 'custom_id' => sprintf('%s|%s|%s', $payment->get_id(), $membership->get_id(), $customer->get_id()), + 'amount' => [ + 'currency_code' => $currency, + 'value' => $this->format_amount($payment->get_total(), $currency), + 'breakdown' => [ + 'item_total' => [ + 'currency_code' => $currency, + 'value' => $this->format_amount($payment->get_subtotal(), $currency), + ], + 'tax_total' => [ + 'currency_code' => $currency, + 'value' => $this->format_amount($payment->get_tax_total(), $currency), + ], + ], + ], + 'items' => $this->build_order_items($cart, $currency), + ]; + + // Include payee.merchant_id when connected via OAuth so PayPal routes + // the payment to the correct merchant account (required for PPCP compliance). + if (! empty($this->merchant_id)) { + $purchase_unit['payee'] = [ + 'merchant_id' => $this->merchant_id, + ]; + } + $order_data = [ 'intent' => 'CAPTURE', 'payment_source' => [ @@ -1025,28 +1094,7 @@ protected function create_order($payment, $membership, $customer, $cart, $type): 'email_address' => $customer->get_email_address(), ], ], - 'purchase_units' => [ - [ - 'reference_id' => $payment->get_hash(), - 'description' => substr($description, 0, 127), - 'custom_id' => sprintf('%s|%s|%s', $payment->get_id(), $membership->get_id(), $customer->get_id()), - 'amount' => [ - 'currency_code' => $currency, - 'value' => $this->format_amount($payment->get_total(), $currency), - 'breakdown' => [ - 'item_total' => [ - 'currency_code' => $currency, - 'value' => $this->format_amount($payment->get_subtotal(), $currency), - ], - 'tax_total' => [ - 'currency_code' => $currency, - 'value' => $this->format_amount($payment->get_tax_total(), $currency), - ], - ], - ], - 'items' => $this->build_order_items($cart, $currency), - ], - ], + 'purchase_units' => [ $purchase_unit ], ]; /** @@ -1890,7 +1938,7 @@ function () use ($nonce, $sandbox) { $(".wu-paypal-disconnect").on("click", function(e) { e.preventDefault(); - if (!confirm()) return; + if (!confirm()) return; var $btn = $(this); $btn.prop("disabled", true);