Skip to content

Commit

Permalink
Move IPN Handling to a service
Browse files Browse the repository at this point in the history
  • Loading branch information
mglaman committed Mar 30, 2017
1 parent 45540f7 commit 46a1e17
Show file tree
Hide file tree
Showing 8 changed files with 304 additions and 152 deletions.
8 changes: 8 additions & 0 deletions commerce_paypal.services.yml
Original file line number Diff line number Diff line change
@@ -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']
112 changes: 112 additions & 0 deletions src/IPNHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

namespace Drupal\commerce_paypal;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use GuzzleHttp\ClientInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

class IPNHandler implements IPNHandlerInterface {

/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;

/**
* The logger.
*
* @var \Drupal\Core\Logger\LoggerChannelInterface
*/
protected $logger;

/**
* The HTTP client.
*
* @var \GuzzleHttp\Client
*/
protected $httpClient;

/**
* Constructs a new PaymentGatewayBase object.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Psr\Log\LoggerInterface $logger
* The logger channel.
* @param \GuzzleHttp\ClientInterface $client
* The client.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, LoggerInterface $logger, ClientInterface $client) {
$this->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';
}
}

}
25 changes: 25 additions & 0 deletions src/IPNHandlerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace Drupal\commerce_paypal;

use Symfony\Component\HttpFoundation\Request;

/**
* Provides a handler for IPN requests from PayPal.
*/
interface IPNHandlerInterface {

/**
* Processes an incoming IPN request.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @return mixed
* The request data array.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public function process(Request $request);

}
92 changes: 76 additions & 16 deletions src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@

namespace Drupal\commerce_paypal\Plugin\Commerce\PaymentGateway;

use Drupal\commerce\TimeInterface;
use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\commerce_payment\Entity\PaymentInterface;
use Drupal\commerce_payment\Exception\InvalidRequestException;
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;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use GuzzleHttp\ClientInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\DependencyInjection\ContainerInterface;
Expand All @@ -32,7 +35,14 @@
* },
* )
*/
class ExpressCheckout extends PayPalIPNGatewayBase implements ExpressCheckoutInterface {
class ExpressCheckout extends OffsitePaymentGatewayBase implements ExpressCheckoutInterface {

/**
* The logger.
*
* @var \Drupal\Core\Logger\LoggerChannelInterface
*/
protected $logger;

/**
* The HTTP client.
Expand All @@ -42,19 +52,49 @@ class ExpressCheckout extends PayPalIPNGatewayBase implements ExpressCheckoutInt
protected $httpClient;

/**
* The rounder.
* The price rounder.
*
* @var \Drupal\commerce_price\RounderInterface
*/
protected $rounder;

/**
* {@inheritdoc}
* The time.
*
* @var \Drupal\commerce\TimeInterface
*/
protected $time;

/**
* Constructs a new PaymentGatewayBase object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\commerce_payment\PaymentTypeManager $payment_type_manager
* The payment type manager.
* @param \Drupal\commerce_payment\PaymentMethodTypeManager $payment_method_type_manager
* The payment method type manager.
* @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_channel_factory
* The logger channel factory.
* @param \GuzzleHttp\ClientInterface $client
* The client.
* @param \Drupal\commerce_price\RounderInterface $rounder
* The price rounder.
* @param \Drupal\commerce\TimeInterface $time
* The time.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, PaymentTypeManager $payment_type_manager, PaymentMethodTypeManager $payment_method_type_manager, ClientInterface $client, RounderInterface $rounder) {
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, PaymentTypeManager $payment_type_manager, PaymentMethodTypeManager $payment_method_type_manager, LoggerChannelFactoryInterface $logger_channel_factory, ClientInterface $client, RounderInterface $rounder, TimeInterface $time) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager, $payment_type_manager, $payment_method_type_manager);
$this->logger = $logger_channel_factory->get('commerce_paypal');
$this->httpClient = $client;
$this->rounder = $rounder;
$this->time = $time;
}

/**
Expand All @@ -68,8 +108,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')
);
}

Expand Down Expand Up @@ -195,13 +237,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(),
Expand All @@ -214,7 +256,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':
Expand Down Expand Up @@ -358,7 +400,7 @@ public function onNotify(Request $request) {
// 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.
Expand All @@ -372,7 +414,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']);
Expand All @@ -389,7 +431,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.
Expand All @@ -399,15 +441,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);
Expand All @@ -425,7 +466,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)) {
Expand Down Expand Up @@ -683,4 +724,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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Loading

0 comments on commit 46a1e17

Please sign in to comment.