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; } /** @@ -69,8 +120,11 @@ 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'), + $container->get('commerce_paypal.ipn_handler') ); } @@ -197,13 +251,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(), @@ -216,7 +270,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': @@ -346,6 +400,95 @@ 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->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'])) { + $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'])) { + 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'])) { + // 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) { + $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']); + $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($this->time->getRequestTime()); + 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) { + $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') { + $this->logger->warning('IPN for Order @order_number ignored: the transaction is already refunded.', ['@order_number' => $ipn_data['invoice']]); + return FALSE; + } + $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); + 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. + $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)) { + $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} */ @@ -592,4 +735,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 90baaeb..06f7a20 100644 --- a/src/Plugin/Commerce/PaymentGateway/ExpressCheckoutInterface.php +++ b/src/Plugin/Commerce/PaymentGateway/ExpressCheckoutInterface.php @@ -4,14 +4,13 @@ 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; /** * Provides the interface for the Express Checkout payment gateway. */ -interface ExpressCheckoutInterface extends OffsitePaymentGatewayInterface, SupportsAuthorizationsInterface, SupportsRefundsInterface { +interface ExpressCheckoutInterface extends SupportsAuthorizationsInterface, SupportsRefundsInterface { /** * Gets the API URL. @@ -36,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/tests/src/Kernel/IPNHandlerTest.php b/tests/src/Kernel/IPNHandlerTest.php new file mode 100644 index 0000000..96c18fd --- /dev/null +++ b/tests/src/Kernel/IPNHandlerTest.php @@ -0,0 +1,80 @@ +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'); + } + +}