From 6e9ac25320319a2c760a1227ba4e2851d1e79f73 Mon Sep 17 00:00:00 2001 From: tavi toporjinschi Date: Fri, 10 Feb 2017 14:05:34 +0000 Subject: [PATCH 1/8] Issue #2843999 by mglaman, bojanz, vasike: Implement IPN handler. --- .../PaymentGateway/ExpressCheckout.php | 93 ++++++++++++++ .../PaypalPaymentGatewayTrait.php | 113 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php diff --git a/src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php b/src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php index 7c2f484..682dcd1 100644 --- a/src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php +++ b/src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php @@ -35,6 +35,8 @@ */ class ExpressCheckout extends OffsitePaymentGatewayBase implements ExpressCheckoutInterface { + use PaypalPaymentGatewayTrait; + /** * The HTTP client. * @@ -344,6 +346,97 @@ public function refundPayment(PaymentInterface $payment, Price $amount = NULL) { $payment->save(); } + /** + * {@inheritdoc} + */ + public function onNotify(Request $request) { + // Get IPN request data and basic processing for the IPN request. + $ipn_data = $this->processIpnRequest($request); + if (!$ipn_data) { + return FALSE; + } + + // Do not perform any processing on EC transactions here that do not have + // transaction IDs, indicating they are non-payment IPNs such as those used + // for subscription signup requests. + if (empty($ipn_data['txn_id'])) { + \Drupal::logger('commerce_paypal')->alert('The IPN request does not have a transaction id. Ignored.'); + return FALSE; + } + // Exit when we don't get a payment status we recognize. + if (!in_array($ipn_data['payment_status'], ['Failed', 'Voided', 'Pending', 'Completed', 'Refunded'])) { + return FALSE; + } + // If this is a prior authorization capture IPN... + if (in_array($ipn_data['payment_status'], ['Voided', 'Pending', 'Completed']) && !empty($ipn_data['auth_id'])) { + // Ensure we can load the existing corresponding transaction. + $payment = $this->loadPaymentByRemoteId($ipn_data['auth_id']); + // If not, bail now because authorization transactions should be created + // by the Express Checkout API request itself. + if (!$payment) { + \Drupal::logger('commerce_paypal')->warning('IPN for Order @order_number ignored: authorization transaction already created.', ['@order_number' => $ipn_data['invoice']]); + return FALSE; + } + $amount = new Price($ipn_data['mc_gross'], $ipn_data['mc_currency']); + $payment->setAmount($amount); + // Update the payment state. + switch ($ipn_data['payment_status']) { + case 'Voided': + $payment->state = 'authorization_voided'; + break; + case 'Pending': + $payment->state = 'authorization'; + break; + case 'Completed': + $payment->state = 'capture_completed'; + $payment->setCapturedTime(REQUEST_TIME); + break; + } + // Update the remote id. + $payment->remote_id = $ipn_data['txn_id']; + } + elseif ($ipn_data['payment_status'] == 'Refunded') { + // Get the corresponding parent transaction and refund it. + $payment = $this->loadPaymentByRemoteId($ipn_data['parent_txn_id']); + if (!$payment) { + \Drupal::logger('commerce_paypal')->warning('IPN for Order @order_number ignored: the transaction to be refunded does not exist.', ['@order_number' => $ipn_data['invoice']]); + return FALSE; + } + elseif ($payment->getState() == 'capture_refunded') { + \Drupal::logger('commerce_paypal')->warning('IPN for Order @order_number ignored: the transaction is already refunded.', ['@order_number' => $ipn_data['invoice']]); + return FALSE; + } + $amount_number = abs($ipn_data['mc_gross']); + $amount = new Price((string) $amount_number, $ipn_data['mc_currency']); + // Check if the Refund is partial or full. + $old_refunded_amount = $payment->getRefundedAmount(); + $new_refunded_amount = $old_refunded_amount->add($amount); + if ($new_refunded_amount->lessThan($payment->getAmount())) { + $payment->state = 'capture_partially_refunded'; + } + else { + $payment->state = 'capture_refunded'; + } + $payment->setRefundedAmount($new_refunded_amount); + } + elseif ($ipn_data['payment_status'] == 'Failed') { + // ToDo - to check and report existing payments??? + } + else { + // In other circumstances, exit the processing, because we handle those + // cases directly during API response processing. + \Drupal::logger('commerce_paypal')->notice('IPN for Order @order_number ignored: this operation was accommodated in the direct API response.', ['@order_number' => $ipn_data['invoice']]); + return FALSE; + } + if (isset($payment)) { + $payment->currency_code = $ipn_data['mc_currency']; + // Set the transaction's statuses based on the IPN's payment_status. + $payment->remote_state = $ipn_data['payment_status']; + // Save the transaction information. + $payment->save(); + } + } + /** * {@inheritdoc} */ diff --git a/src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php b/src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php new file mode 100644 index 0000000..1964e65 --- /dev/null +++ b/src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php @@ -0,0 +1,113 @@ +getStorage('commerce_payment'); + $payment_by_remote_id = $storage->loadByProperties(['remote_id' => $remote_id]); + return reset($payment_by_remote_id); + } + + /** + * Processes an incoming IPN request. + * + * @param Request $request + * The request. + * + * @return mixed + * The request data array or FALSE. + */ + public function processIpnRequest(Request $request) { + // Validate and get IPN request data. + $ipn_data = $this->getIpnRequestValidate($request); + + // ToDo other general validations for IPN data. + + return $ipn_data; + } + + /** + * Validate an incoming IPN request and return the request data for extra + * processing. + * + * @param Request $request + * The request. + * + * @return mixed + * The request data array or FALSE. + */ + public function getIpnRequestValidate(Request $request) { + // Get IPN request data. + $ipn_data = $this->getRequestDataArray($request->getContent()); + + // Exit now if the $_POST was empty. + if (empty($ipn_data)) { + \Drupal::logger('commerce_paypal')->warning('IPN URL accessed with no POST data submitted.'); + return FALSE; + } + + // Make PayPal request for IPN validation. + $url = $this->getIpnValidationUrl($ipn_data); + $validate_ipn = 'cmd=_notify-validate&' . $request->getContent(); + $request = \Drupal::httpClient()->post($url, [ + 'body' => $validate_ipn, + ])->getBody(); + $paypal_response = $this->getRequestDataArray($request->getContents()); + + // If the IPN was invalid, log a message and exit. + if (isset($paypal_response['INVALID'])) { + \Drupal::logger('commerce_paypal')->alert('Invalid IPN received and ignored.'); + return FALSE; + } + return $ipn_data; + } + + /** + * Get data array from a request content. + * + * @param string $request_content + * The Request content. + * + * @return array + * The request data array. + */ + public function getRequestDataArray($request_content) { + parse_str(html_entity_decode($request_content), $ipn_data); + return $ipn_data; + } + + /** + * Gets the IPN URL to be used for validation for IPN data. + * + * @param array $ipn_data + * The IPN request data from PayPal. + * + * @return string + * The IPN validation URL. + */ + public function getIpnValidationUrl($ipn_data) { + if (!empty($ipn_data['test_ipn']) && $ipn_data['test_ipn'] == 1) { + return 'https://www.sandbox.paypal.com/cgi-bin/webscr'; + } + else { + return 'https://www.paypal.com/cgi-bin/webscr'; + } + } +} From a0d50f46bb1d9069a94eec428f9389104dbed387 Mon Sep 17 00:00:00 2001 From: tavi toporjinschi Date: Fri, 24 Feb 2017 15:39:00 +0000 Subject: [PATCH 2/8] Some fixes for travis errors. --- src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php | 2 ++ .../Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php b/src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php index 682dcd1..65608c5 100644 --- a/src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php +++ b/src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php @@ -384,9 +384,11 @@ public function onNotify(Request $request) { case 'Voided': $payment->state = 'authorization_voided'; break; + case 'Pending': $payment->state = 'authorization'; break; + case 'Completed': $payment->state = 'capture_completed'; $payment->setCapturedTime(REQUEST_TIME); diff --git a/src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php b/src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php index 1964e65..081b077 100644 --- a/src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php +++ b/src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php @@ -15,7 +15,7 @@ trait PaypalPaymentGatewayTrait { * @param string $remote_id * The remote id property for a payment. * - * @return \Drupal\commerce_payment\Entity\PaymentInterface. + * @return \Drupal\commerce_payment\Entity\PaymentInterface * Payment object. */ public function loadPaymentByRemoteId($remote_id) { @@ -39,7 +39,6 @@ public function processIpnRequest(Request $request) { $ipn_data = $this->getIpnRequestValidate($request); // ToDo other general validations for IPN data. - return $ipn_data; } @@ -102,7 +101,7 @@ public function getRequestDataArray($request_content) { * @return string * The IPN validation URL. */ - public function getIpnValidationUrl($ipn_data) { + public function getIpnValidationUrl(array $ipn_data) { if (!empty($ipn_data['test_ipn']) && $ipn_data['test_ipn'] == 1) { return 'https://www.sandbox.paypal.com/cgi-bin/webscr'; } @@ -110,4 +109,5 @@ public function getIpnValidationUrl($ipn_data) { return 'https://www.paypal.com/cgi-bin/webscr'; } } + } From 0bac0a3cceae721b3d8a285d419baebaea925a7b Mon Sep 17 00:00:00 2001 From: tavi toporjinschi Date: Mon, 27 Feb 2017 09:56:24 +0000 Subject: [PATCH 3/8] PR requested changes. --- .../PaypalPaymentGatewayTrait.php | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php b/src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php index 081b077..99dc8c5 100644 --- a/src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php +++ b/src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php @@ -17,6 +17,9 @@ trait PaypalPaymentGatewayTrait { * * @return \Drupal\commerce_payment\Entity\PaymentInterface * Payment object. + * + * @todo: to be replaced by Commerce core payment storage method + * @see https://www.drupal.org/node/2856209 */ public function loadPaymentByRemoteId($remote_id) { /** @var \Drupal\commerce_payment\PaymentStorage $storage */ @@ -35,24 +38,6 @@ public function loadPaymentByRemoteId($remote_id) { * The request data array or FALSE. */ public function processIpnRequest(Request $request) { - // Validate and get IPN request data. - $ipn_data = $this->getIpnRequestValidate($request); - - // ToDo other general validations for IPN data. - return $ipn_data; - } - - /** - * Validate an incoming IPN request and return the request data for extra - * processing. - * - * @param Request $request - * The request. - * - * @return mixed - * The request data array or FALSE. - */ - public function getIpnRequestValidate(Request $request) { // Get IPN request data. $ipn_data = $this->getRequestDataArray($request->getContent()); @@ -75,6 +60,8 @@ public function getIpnRequestValidate(Request $request) { \Drupal::logger('commerce_paypal')->alert('Invalid IPN received and ignored.'); return FALSE; } + + // ToDo other general validations for IPN data. return $ipn_data; } From d2a13066caacc3a94916a3e79e36476eb2067217 Mon Sep 17 00:00:00 2001 From: tavi toporjinschi Date: Tue, 14 Mar 2017 12:48:26 +0000 Subject: [PATCH 4/8] Travis corrections. --- .../Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php b/src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php index 99dc8c5..3d4b312 100644 --- a/src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php +++ b/src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php @@ -31,7 +31,7 @@ public function loadPaymentByRemoteId($remote_id) { /** * Processes an incoming IPN request. * - * @param Request $request + * @param \Symfony\Component\HttpFoundation\Request $request * The request. * * @return mixed From 79125fa7dabcbe9ee2d6724f7728ae1cdcc4967b Mon Sep 17 00:00:00 2001 From: tavi toporjinschi Date: Thu, 30 Mar 2017 09:32:04 +0000 Subject: [PATCH 5/8] Use abstract PayPalIPNGatewayBase class insted of trait. --- .../PaymentGateway/ExpressCheckout.php | 5 +- .../ExpressCheckoutInterface.php | 2 +- ...ewayTrait.php => PayPalIPNGatewayBase.php} | 38 ++---------- .../PayPalIPNGatewayBaseInterface.php | 60 +++++++++++++++++++ 4 files changed, 68 insertions(+), 37 deletions(-) rename src/Plugin/Commerce/PaymentGateway/{PaypalPaymentGatewayTrait.php => PayPalIPNGatewayBase.php} (68%) create mode 100644 src/Plugin/Commerce/PaymentGateway/PayPalIPNGatewayBaseInterface.php diff --git a/src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php b/src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php index 4bd2ce9..d2d9162 100644 --- a/src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php +++ b/src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php @@ -8,7 +8,6 @@ use Drupal\commerce_payment\Exception\PaymentGatewayException; use Drupal\commerce_payment\PaymentMethodTypeManager; use Drupal\commerce_payment\PaymentTypeManager; -use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OffsitePaymentGatewayBase; use Drupal\commerce_price\Price; use Drupal\commerce_price\RounderInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; @@ -33,9 +32,7 @@ * }, * ) */ -class ExpressCheckout extends OffsitePaymentGatewayBase implements ExpressCheckoutInterface { - - use PaypalPaymentGatewayTrait; +class ExpressCheckout extends PayPalIPNGatewayBase implements ExpressCheckoutInterface { /** * The HTTP client. diff --git a/src/Plugin/Commerce/PaymentGateway/ExpressCheckoutInterface.php b/src/Plugin/Commerce/PaymentGateway/ExpressCheckoutInterface.php index 90baaeb..a827ca4 100644 --- a/src/Plugin/Commerce/PaymentGateway/ExpressCheckoutInterface.php +++ b/src/Plugin/Commerce/PaymentGateway/ExpressCheckoutInterface.php @@ -11,7 +11,7 @@ /** * Provides the interface for the Express Checkout payment gateway. */ -interface ExpressCheckoutInterface extends OffsitePaymentGatewayInterface, SupportsAuthorizationsInterface, SupportsRefundsInterface { +interface ExpressCheckoutInterface extends PayPalIPNGatewayBaseInterface, SupportsAuthorizationsInterface, SupportsRefundsInterface { /** * Gets the API URL. diff --git a/src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php b/src/Plugin/Commerce/PaymentGateway/PayPalIPNGatewayBase.php similarity index 68% rename from src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php rename to src/Plugin/Commerce/PaymentGateway/PayPalIPNGatewayBase.php index 3d4b312..03ae935 100644 --- a/src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php +++ b/src/Plugin/Commerce/PaymentGateway/PayPalIPNGatewayBase.php @@ -2,24 +2,16 @@ namespace Drupal\commerce_paypal\Plugin\Commerce\PaymentGateway; +use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OffsitePaymentGatewayBase; use Symfony\Component\HttpFoundation\Request; /** * Provides common methods to be used by PayPal payment gateways. */ -trait PaypalPaymentGatewayTrait { +abstract class PayPalIPNGatewayBase extends OffsitePaymentGatewayBase implements PayPalIPNGatewayBaseInterface { /** - * Loads the payment for a given remote id. - * - * @param string $remote_id - * The remote id property for a payment. - * - * @return \Drupal\commerce_payment\Entity\PaymentInterface - * Payment object. - * - * @todo: to be replaced by Commerce core payment storage method - * @see https://www.drupal.org/node/2856209 + * {@inheritdoc} */ public function loadPaymentByRemoteId($remote_id) { /** @var \Drupal\commerce_payment\PaymentStorage $storage */ @@ -29,13 +21,7 @@ public function loadPaymentByRemoteId($remote_id) { } /** - * Processes an incoming IPN request. - * - * @param \Symfony\Component\HttpFoundation\Request $request - * The request. - * - * @return mixed - * The request data array or FALSE. + * {@inheritdoc} */ public function processIpnRequest(Request $request) { // Get IPN request data. @@ -66,13 +52,7 @@ public function processIpnRequest(Request $request) { } /** - * Get data array from a request content. - * - * @param string $request_content - * The Request content. - * - * @return array - * The request data array. + * {@inheritdoc} */ public function getRequestDataArray($request_content) { parse_str(html_entity_decode($request_content), $ipn_data); @@ -80,13 +60,7 @@ public function getRequestDataArray($request_content) { } /** - * Gets the IPN URL to be used for validation for IPN data. - * - * @param array $ipn_data - * The IPN request data from PayPal. - * - * @return string - * The IPN validation URL. + * {@inheritdoc} */ public function getIpnValidationUrl(array $ipn_data) { if (!empty($ipn_data['test_ipn']) && $ipn_data['test_ipn'] == 1) { diff --git a/src/Plugin/Commerce/PaymentGateway/PayPalIPNGatewayBaseInterface.php b/src/Plugin/Commerce/PaymentGateway/PayPalIPNGatewayBaseInterface.php new file mode 100644 index 0000000..822fe7b --- /dev/null +++ b/src/Plugin/Commerce/PaymentGateway/PayPalIPNGatewayBaseInterface.php @@ -0,0 +1,60 @@ + Date: Thu, 30 Mar 2017 07:05:43 -0500 Subject: [PATCH 6/8] Update ExpressCheckoutInterface.php --- src/Plugin/Commerce/PaymentGateway/ExpressCheckoutInterface.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Plugin/Commerce/PaymentGateway/ExpressCheckoutInterface.php b/src/Plugin/Commerce/PaymentGateway/ExpressCheckoutInterface.php index a827ca4..29d9b6d 100644 --- a/src/Plugin/Commerce/PaymentGateway/ExpressCheckoutInterface.php +++ b/src/Plugin/Commerce/PaymentGateway/ExpressCheckoutInterface.php @@ -4,7 +4,6 @@ use Drupal\commerce_order\Entity\OrderInterface; use Drupal\commerce_payment\Entity\PaymentInterface; -use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OffsitePaymentGatewayInterface; use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsAuthorizationsInterface; use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsRefundsInterface; From 7285d566897cb81b700206f45febe7febdfd54eb Mon Sep 17 00:00:00 2001 From: Matt Glaman Date: Thu, 30 Mar 2017 16:48:53 -0500 Subject: [PATCH 7/8] Move IPN Handling to a service --- commerce_paypal.services.yml | 8 ++ src/IPNHandler.php | 112 ++++++++++++++++++ src/IPNHandlerInterface.php | 25 ++++ .../PaymentGateway/ExpressCheckout.php | 111 +++++++++++++---- .../ExpressCheckoutInterface.php | 5 +- .../PaymentGateway/PayPalIPNGatewayBase.php | 74 ------------ .../PayPalIPNGatewayBaseInterface.php | 60 ---------- tests/src/Kernel/IPNHandlerTest.php | 80 +++++++++++++ 8 files changed, 318 insertions(+), 157 deletions(-) create mode 100644 commerce_paypal.services.yml create mode 100644 src/IPNHandler.php create mode 100644 src/IPNHandlerInterface.php delete mode 100644 src/Plugin/Commerce/PaymentGateway/PayPalIPNGatewayBase.php delete mode 100644 src/Plugin/Commerce/PaymentGateway/PayPalIPNGatewayBaseInterface.php create mode 100644 tests/src/Kernel/IPNHandlerTest.php diff --git a/commerce_paypal.services.yml b/commerce_paypal.services.yml new file mode 100644 index 0000000..59241df --- /dev/null +++ b/commerce_paypal.services.yml @@ -0,0 +1,8 @@ +services: + commerce_paypal.logger: + class: Drupal\Core\Logger\LoggerChannel + factory: logger.factory:get + arguments: ['commerce_paypal'] + commerce_paypal.ipn_handler: + class: Drupal\commerce_paypal\IPNHandler + arguments: ['@entity_type.manager', '@commerce_paypal.logger', '@http_client'] diff --git a/src/IPNHandler.php b/src/IPNHandler.php new file mode 100644 index 0000000..5d49d35 --- /dev/null +++ b/src/IPNHandler.php @@ -0,0 +1,112 @@ +entityTypeManager = $entity_type_manager; + $this->logger = $logger; + $this->httpClient = $client; + } + + /** + * {@inheritdoc} + */ + public function process(Request $request) { + // Get IPN request data. + $ipn_data = $this->getRequestDataArray($request->getContent()); + + // Exit now if the $_POST was empty. + if (empty($ipn_data)) { + $this->logger->warning('IPN URL accessed with no POST data submitted.'); + throw new BadRequestHttpException('IPN URL accessed with no POST data submitted.'); + } + + // Make PayPal request for IPN validation. + $url = $this->getIpnValidationUrl($ipn_data); + $validate_ipn = 'cmd=_notify-validate&' . $request->getContent(); + $request = $this->httpClient->post($url, [ + 'body' => $validate_ipn, + ])->getBody(); + $paypal_response = $this->getRequestDataArray($request->getContents()); + + // If the IPN was invalid, log a message and exit. + if (isset($paypal_response['INVALID'])) { + $this->logger->alert('Invalid IPN received and ignored.'); + throw new BadRequestHttpException('Invalid IPN received and ignored.'); + } + + return $ipn_data; + } + + /** + * Get data array from a request content. + * + * @param string $request_content + * The Request content. + * + * @return array + * The request data array. + */ + protected function getRequestDataArray($request_content) { + parse_str(html_entity_decode($request_content), $ipn_data); + return $ipn_data; + } + + /** + * Gets the IPN URL to be used for validation for IPN data. + * + * @param array $ipn_data + * The IPN request data from PayPal. + * + * @return string + * The IPN validation URL. + */ + protected function getIpnValidationUrl(array $ipn_data) { + if (!empty($ipn_data['test_ipn']) && $ipn_data['test_ipn'] == 1) { + return 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr'; + } + else { + return 'https://ipnpb.paypal.com/cgi-bin/webscr'; + } + } + +} diff --git a/src/IPNHandlerInterface.php b/src/IPNHandlerInterface.php new file mode 100644 index 0000000..f4316ba --- /dev/null +++ b/src/IPNHandlerInterface.php @@ -0,0 +1,25 @@ +logger = $logger_channel_factory->get('commerce_paypal'); $this->httpClient = $client; $this->rounder = $rounder; + $this->time = $time; + $this->ipnHandler = $ip_handler; } /** @@ -68,8 +120,10 @@ public static function create(ContainerInterface $container, array $configuratio $container->get('entity_type.manager'), $container->get('plugin.manager.commerce_payment_type'), $container->get('plugin.manager.commerce_payment_method_type'), + $container->get('logger.factory'), $container->get('http_client'), - $container->get('commerce_price.rounder') + $container->get('commerce_price.rounder'), + $container->get('commerce.time') ); } @@ -195,13 +249,13 @@ public function onReturn(OrderInterface $order, Request $request) { $paypal_response = $this->doExpressCheckoutDetails($order); // Nothing to do for failures for now - no payment saved. - // ToDo - more about the failures. + // @todo - more about the failures. if ($paypal_response['PAYMENTINFO_0_PAYMENTSTATUS'] == 'Failed') { throw new PaymentGatewayException($paypal_response['PAYMENTINFO_0_LONGMESSAGE'], $paypal_response['PAYMENTINFO_0_ERRORCODE']); } $payment_storage = $this->entityTypeManager->getStorage('commerce_payment'); - $request_time = \Drupal::service('commerce.time')->getRequestTime(); + $request_time = $this->time->getRequestTime(); $payment = $payment_storage->create([ 'state' => 'authorization', 'amount' => $order->getTotalPrice(), @@ -214,7 +268,7 @@ public function onReturn(OrderInterface $order, Request $request) { ]); // Process payment status received. - // ToDo : payment updates if needed. + // @todo payment updates if needed. // If we didn't get an approval response code... switch ($paypal_response['PAYMENTINFO_0_PAYMENTSTATUS']) { case 'Voided': @@ -349,21 +403,18 @@ public function refundPayment(PaymentInterface $payment, Price $amount = NULL) { */ public function onNotify(Request $request) { // Get IPN request data and basic processing for the IPN request. - $ipn_data = $this->processIpnRequest($request); - if (!$ipn_data) { - return FALSE; - } + $ipn_data = $this->ipnHandler->process($request); // Do not perform any processing on EC transactions here that do not have // transaction IDs, indicating they are non-payment IPNs such as those used // for subscription signup requests. if (empty($ipn_data['txn_id'])) { - \Drupal::logger('commerce_paypal')->alert('The IPN request does not have a transaction id. Ignored.'); + $this->logger->alert('The IPN request does not have a transaction id. Ignored.'); return FALSE; } // Exit when we don't get a payment status we recognize. if (!in_array($ipn_data['payment_status'], ['Failed', 'Voided', 'Pending', 'Completed', 'Refunded'])) { - return FALSE; + throw new BadRequestHttpException('Invalid payment status'); } // If this is a prior authorization capture IPN... if (in_array($ipn_data['payment_status'], ['Voided', 'Pending', 'Completed']) && !empty($ipn_data['auth_id'])) { @@ -372,7 +423,7 @@ public function onNotify(Request $request) { // If not, bail now because authorization transactions should be created // by the Express Checkout API request itself. if (!$payment) { - \Drupal::logger('commerce_paypal')->warning('IPN for Order @order_number ignored: authorization transaction already created.', ['@order_number' => $ipn_data['invoice']]); + $this->logger->warning('IPN for Order @order_number ignored: authorization transaction already created.', ['@order_number' => $ipn_data['invoice']]); return FALSE; } $amount = new Price($ipn_data['mc_gross'], $ipn_data['mc_currency']); @@ -389,7 +440,7 @@ public function onNotify(Request $request) { case 'Completed': $payment->state = 'capture_completed'; - $payment->setCapturedTime(REQUEST_TIME); + $payment->setCapturedTime($this->time->getRequestTime()); break; } // Update the remote id. @@ -399,15 +450,14 @@ public function onNotify(Request $request) { // Get the corresponding parent transaction and refund it. $payment = $this->loadPaymentByRemoteId($ipn_data['parent_txn_id']); if (!$payment) { - \Drupal::logger('commerce_paypal')->warning('IPN for Order @order_number ignored: the transaction to be refunded does not exist.', ['@order_number' => $ipn_data['invoice']]); + $this->logger->warning('IPN for Order @order_number ignored: the transaction to be refunded does not exist.', ['@order_number' => $ipn_data['invoice']]); return FALSE; } elseif ($payment->getState() == 'capture_refunded') { - \Drupal::logger('commerce_paypal')->warning('IPN for Order @order_number ignored: the transaction is already refunded.', ['@order_number' => $ipn_data['invoice']]); + $this->logger->warning('IPN for Order @order_number ignored: the transaction is already refunded.', ['@order_number' => $ipn_data['invoice']]); return FALSE; } - $amount_number = abs($ipn_data['mc_gross']); - $amount = new Price((string) $amount_number, $ipn_data['mc_currency']); + $amount = new Price((string) $ipn_data['mc_gross'], $ipn_data['mc_currency']); // Check if the Refund is partial or full. $old_refunded_amount = $payment->getRefundedAmount(); $new_refunded_amount = $old_refunded_amount->add($amount); @@ -425,7 +475,7 @@ public function onNotify(Request $request) { else { // In other circumstances, exit the processing, because we handle those // cases directly during API response processing. - \Drupal::logger('commerce_paypal')->notice('IPN for Order @order_number ignored: this operation was accommodated in the direct API response.', ['@order_number' => $ipn_data['invoice']]); + $this->logger->notice('IPN for Order @order_number ignored: this operation was accommodated in the direct API response.', ['@order_number' => $ipn_data['invoice']]); return FALSE; } if (isset($payment)) { @@ -683,4 +733,23 @@ public function doRequest(array $nvp_data) { return $paypal_response; } + /** + * Loads the payment for a given remote id. + * + * @param string $remote_id + * The remote id property for a payment. + * + * @return \Drupal\commerce_payment\Entity\PaymentInterface + * Payment object. + * + * @todo: to be replaced by Commerce core payment storage method + * @see https://www.drupal.org/node/2856209 + */ + protected function loadPaymentByRemoteId($remote_id) { + /** @var \Drupal\commerce_payment\PaymentStorage $storage */ + $storage = $this->entityTypeManager->getStorage('commerce_payment'); + $payment_by_remote_id = $storage->loadByProperties(['remote_id' => $remote_id]); + return reset($payment_by_remote_id); + } + } diff --git a/src/Plugin/Commerce/PaymentGateway/ExpressCheckoutInterface.php b/src/Plugin/Commerce/PaymentGateway/ExpressCheckoutInterface.php index 29d9b6d..06f7a20 100644 --- a/src/Plugin/Commerce/PaymentGateway/ExpressCheckoutInterface.php +++ b/src/Plugin/Commerce/PaymentGateway/ExpressCheckoutInterface.php @@ -10,7 +10,7 @@ /** * Provides the interface for the Express Checkout payment gateway. */ -interface ExpressCheckoutInterface extends PayPalIPNGatewayBaseInterface, SupportsAuthorizationsInterface, SupportsRefundsInterface { +interface ExpressCheckoutInterface extends SupportsAuthorizationsInterface, SupportsRefundsInterface { /** * Gets the API URL. @@ -35,12 +35,13 @@ public function doRequest(array $nvp_data); /** * SetExpressCheckout API Operation (NVP) request. + * * Builds the data for the request and make the request. * * @param \Drupal\commerce_payment\Entity\PaymentInterface $payment * The payment. * @param array $extra - * Extra data needed for this request, ex.: cancel url, return url, transaction mode, etc.... + * Extra data needed for this request. * * @return array * PayPal response data. diff --git a/src/Plugin/Commerce/PaymentGateway/PayPalIPNGatewayBase.php b/src/Plugin/Commerce/PaymentGateway/PayPalIPNGatewayBase.php deleted file mode 100644 index 03ae935..0000000 --- a/src/Plugin/Commerce/PaymentGateway/PayPalIPNGatewayBase.php +++ /dev/null @@ -1,74 +0,0 @@ -getStorage('commerce_payment'); - $payment_by_remote_id = $storage->loadByProperties(['remote_id' => $remote_id]); - return reset($payment_by_remote_id); - } - - /** - * {@inheritdoc} - */ - public function processIpnRequest(Request $request) { - // Get IPN request data. - $ipn_data = $this->getRequestDataArray($request->getContent()); - - // Exit now if the $_POST was empty. - if (empty($ipn_data)) { - \Drupal::logger('commerce_paypal')->warning('IPN URL accessed with no POST data submitted.'); - return FALSE; - } - - // Make PayPal request for IPN validation. - $url = $this->getIpnValidationUrl($ipn_data); - $validate_ipn = 'cmd=_notify-validate&' . $request->getContent(); - $request = \Drupal::httpClient()->post($url, [ - 'body' => $validate_ipn, - ])->getBody(); - $paypal_response = $this->getRequestDataArray($request->getContents()); - - // If the IPN was invalid, log a message and exit. - if (isset($paypal_response['INVALID'])) { - \Drupal::logger('commerce_paypal')->alert('Invalid IPN received and ignored.'); - return FALSE; - } - - // ToDo other general validations for IPN data. - return $ipn_data; - } - - /** - * {@inheritdoc} - */ - public function getRequestDataArray($request_content) { - parse_str(html_entity_decode($request_content), $ipn_data); - return $ipn_data; - } - - /** - * {@inheritdoc} - */ - public function getIpnValidationUrl(array $ipn_data) { - if (!empty($ipn_data['test_ipn']) && $ipn_data['test_ipn'] == 1) { - return 'https://www.sandbox.paypal.com/cgi-bin/webscr'; - } - else { - return 'https://www.paypal.com/cgi-bin/webscr'; - } - } - -} diff --git a/src/Plugin/Commerce/PaymentGateway/PayPalIPNGatewayBaseInterface.php b/src/Plugin/Commerce/PaymentGateway/PayPalIPNGatewayBaseInterface.php deleted file mode 100644 index 822fe7b..0000000 --- a/src/Plugin/Commerce/PaymentGateway/PayPalIPNGatewayBaseInterface.php +++ /dev/null @@ -1,60 +0,0 @@ -handler = $this->container->get('commerce_paypal.ipn_handler'); + } + + /** + * Tests when IPN body is empty. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * @expectedExceptionMessage IPN URL accessed with no POST data submitted. + */ + public function testEmptyBody() { + $this->handler->process(new Request()); + } + + /** + * Tests when IPN request marked invalid.. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * @expectedExceptionMessage Invalid IPN received and ignored. + */ + public function testInvalidIpn() { + $this->handler->process($this->createSampleIpnRequest()); + } + + /** + * Creates a request object with testing data. + * + * @see https://developer.paypal.com/docs/classic/ipn/integration-guide/IPNIntro/#id08CKFJ00JYK + * + * @return \Symfony\Component\HttpFoundation\Request + * The request. + */ + protected function createSampleIpnRequest() { + $sample_data = 'mc_gross=19.95&protection_eligibility=Eligible&address_status=confirmed&payer_id=LPLWNMTBWMFAY&tax=0.00&address_street=1+Main+St&payment_date=20%3A12%3A59+Jan+13%2C+2009+PST&payment_status=Completed&charset=windows-1252&address_zip=95131&first_name=Test&mc_fee=0.88&address_country_code=US&address_name=Test+User¬ify_version=2.6&custom=&payer_status=verified&address_country=United+States&address_city=San+Jose&quantity=1&verify_sign=AtkOfCXbDm2hu0ZELryHFjY-Vb7PAUvS6nMXgysbElEn9v-1XcmSoGtf&payer_email=gpmac_1231902590_per%40paypal.com&txn_id=61E67681CH3238416&payment_type=instant&last_name=User&address_state=CA&receiver_email=gpmac_1231902686_biz%40paypal.com&payment_fee=0.88&receiver_id=S8XGHLYDW9T3S&txn_type=express_checkout&item_name=&mc_currency=USD&item_number=&residence_country=US&test_ipn=1&handling_amount=0.00&transaction_subject=&payment_gross=19.95&shipping=0.00'; + return new Request([], [], [], [], [], [], $sample_data); + } + + /** + * {@inheritdoc} + */ + public function alter(ContainerBuilder $container) { + $container->removeDefinition('test.http_client.middleware'); + } + +} From 10ad9d015c3d80f1ce94622a299e6e48154b099e Mon Sep 17 00:00:00 2001 From: tavi toporjinschi Date: Fri, 31 Mar 2017 08:28:20 +0000 Subject: [PATCH 8/8] Missed injecting the service into gateway. --- src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php b/src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php index c37dba3..6bbb473 100644 --- a/src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php +++ b/src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php @@ -123,7 +123,8 @@ public static function create(ContainerInterface $container, array $configuratio $container->get('logger.factory'), $container->get('http_client'), $container->get('commerce_price.rounder'), - $container->get('commerce.time') + $container->get('commerce.time'), + $container->get('commerce_paypal.ipn_handler') ); }