Skip to content

Commit 7285d56

Browse files
committed
Move IPN Handling to a service
1 parent 45540f7 commit 7285d56

File tree

8 files changed

+318
-157
lines changed

8 files changed

+318
-157
lines changed

commerce_paypal.services.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
services:
2+
commerce_paypal.logger:
3+
class: Drupal\Core\Logger\LoggerChannel
4+
factory: logger.factory:get
5+
arguments: ['commerce_paypal']
6+
commerce_paypal.ipn_handler:
7+
class: Drupal\commerce_paypal\IPNHandler
8+
arguments: ['@entity_type.manager', '@commerce_paypal.logger', '@http_client']

src/IPNHandler.php

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php
2+
3+
namespace Drupal\commerce_paypal;
4+
5+
use Drupal\Core\Entity\EntityTypeManagerInterface;
6+
use GuzzleHttp\ClientInterface;
7+
use Psr\Log\LoggerInterface;
8+
use Symfony\Component\HttpFoundation\Request;
9+
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
10+
11+
class IPNHandler implements IPNHandlerInterface {
12+
13+
/**
14+
* The entity type manager.
15+
*
16+
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
17+
*/
18+
protected $entityTypeManager;
19+
20+
/**
21+
* The logger.
22+
*
23+
* @var \Drupal\Core\Logger\LoggerChannelInterface
24+
*/
25+
protected $logger;
26+
27+
/**
28+
* The HTTP client.
29+
*
30+
* @var \GuzzleHttp\Client
31+
*/
32+
protected $httpClient;
33+
34+
/**
35+
* Constructs a new PaymentGatewayBase object.
36+
*
37+
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
38+
* The entity type manager.
39+
* @param \Psr\Log\LoggerInterface $logger
40+
* The logger channel.
41+
* @param \GuzzleHttp\ClientInterface $client
42+
* The client.
43+
*/
44+
public function __construct(EntityTypeManagerInterface $entity_type_manager, LoggerInterface $logger, ClientInterface $client) {
45+
$this->entityTypeManager = $entity_type_manager;
46+
$this->logger = $logger;
47+
$this->httpClient = $client;
48+
}
49+
50+
/**
51+
* {@inheritdoc}
52+
*/
53+
public function process(Request $request) {
54+
// Get IPN request data.
55+
$ipn_data = $this->getRequestDataArray($request->getContent());
56+
57+
// Exit now if the $_POST was empty.
58+
if (empty($ipn_data)) {
59+
$this->logger->warning('IPN URL accessed with no POST data submitted.');
60+
throw new BadRequestHttpException('IPN URL accessed with no POST data submitted.');
61+
}
62+
63+
// Make PayPal request for IPN validation.
64+
$url = $this->getIpnValidationUrl($ipn_data);
65+
$validate_ipn = 'cmd=_notify-validate&' . $request->getContent();
66+
$request = $this->httpClient->post($url, [
67+
'body' => $validate_ipn,
68+
])->getBody();
69+
$paypal_response = $this->getRequestDataArray($request->getContents());
70+
71+
// If the IPN was invalid, log a message and exit.
72+
if (isset($paypal_response['INVALID'])) {
73+
$this->logger->alert('Invalid IPN received and ignored.');
74+
throw new BadRequestHttpException('Invalid IPN received and ignored.');
75+
}
76+
77+
return $ipn_data;
78+
}
79+
80+
/**
81+
* Get data array from a request content.
82+
*
83+
* @param string $request_content
84+
* The Request content.
85+
*
86+
* @return array
87+
* The request data array.
88+
*/
89+
protected function getRequestDataArray($request_content) {
90+
parse_str(html_entity_decode($request_content), $ipn_data);
91+
return $ipn_data;
92+
}
93+
94+
/**
95+
* Gets the IPN URL to be used for validation for IPN data.
96+
*
97+
* @param array $ipn_data
98+
* The IPN request data from PayPal.
99+
*
100+
* @return string
101+
* The IPN validation URL.
102+
*/
103+
protected function getIpnValidationUrl(array $ipn_data) {
104+
if (!empty($ipn_data['test_ipn']) && $ipn_data['test_ipn'] == 1) {
105+
return 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr';
106+
}
107+
else {
108+
return 'https://ipnpb.paypal.com/cgi-bin/webscr';
109+
}
110+
}
111+
112+
}

src/IPNHandlerInterface.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace Drupal\commerce_paypal;
4+
5+
use Symfony\Component\HttpFoundation\Request;
6+
7+
/**
8+
* Provides a handler for IPN requests from PayPal.
9+
*/
10+
interface IPNHandlerInterface {
11+
12+
/**
13+
* Processes an incoming IPN request.
14+
*
15+
* @param \Symfony\Component\HttpFoundation\Request $request
16+
* The request.
17+
*
18+
* @return mixed
19+
* The request data array.
20+
*
21+
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
22+
*/
23+
public function process(Request $request);
24+
25+
}

src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php

Lines changed: 90 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,24 @@
22

33
namespace Drupal\commerce_paypal\Plugin\Commerce\PaymentGateway;
44

5+
use Drupal\commerce\TimeInterface;
56
use Drupal\commerce_order\Entity\OrderInterface;
67
use Drupal\commerce_payment\Entity\PaymentInterface;
78
use Drupal\commerce_payment\Exception\InvalidRequestException;
89
use Drupal\commerce_payment\Exception\PaymentGatewayException;
910
use Drupal\commerce_payment\PaymentMethodTypeManager;
1011
use Drupal\commerce_payment\PaymentTypeManager;
12+
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OffsitePaymentGatewayBase;
13+
use Drupal\commerce_paypal\IPNHandlerInterface;
1114
use Drupal\commerce_price\Price;
1215
use Drupal\commerce_price\RounderInterface;
1316
use Drupal\Core\Entity\EntityTypeManagerInterface;
1417
use Drupal\Core\Form\FormStateInterface;
18+
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
1519
use GuzzleHttp\ClientInterface;
1620
use Symfony\Component\HttpFoundation\Request;
1721
use Symfony\Component\DependencyInjection\ContainerInterface;
22+
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
1823

1924
/**
2025
* Provides the Paypal Express Checkout payment gateway.
@@ -32,7 +37,14 @@
3237
* },
3338
* )
3439
*/
35-
class ExpressCheckout extends PayPalIPNGatewayBase implements ExpressCheckoutInterface {
40+
class ExpressCheckout extends OffsitePaymentGatewayBase implements ExpressCheckoutInterface {
41+
42+
/**
43+
* The logger.
44+
*
45+
* @var \Drupal\Core\Logger\LoggerChannelInterface
46+
*/
47+
protected $logger;
3648

3749
/**
3850
* The HTTP client.
@@ -42,19 +54,59 @@ class ExpressCheckout extends PayPalIPNGatewayBase implements ExpressCheckoutInt
4254
protected $httpClient;
4355

4456
/**
45-
* The rounder.
57+
* The price rounder.
4658
*
4759
* @var \Drupal\commerce_price\RounderInterface
4860
*/
4961
protected $rounder;
5062

5163
/**
52-
* {@inheritdoc}
64+
* The time.
65+
*
66+
* @var \Drupal\commerce\TimeInterface
5367
*/
54-
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) {
68+
protected $time;
69+
70+
/**
71+
* The IPN handler.
72+
*
73+
* @var \Drupal\commerce_paypal\IPNHandlerInterface
74+
*/
75+
protected $ipnHandler;
76+
77+
/**
78+
* Constructs a new PaymentGatewayBase object.
79+
*
80+
* @param array $configuration
81+
* A configuration array containing information about the plugin instance.
82+
* @param string $plugin_id
83+
* The plugin_id for the plugin instance.
84+
* @param mixed $plugin_definition
85+
* The plugin implementation definition.
86+
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
87+
* The entity type manager.
88+
* @param \Drupal\commerce_payment\PaymentTypeManager $payment_type_manager
89+
* The payment type manager.
90+
* @param \Drupal\commerce_payment\PaymentMethodTypeManager $payment_method_type_manager
91+
* The payment method type manager.
92+
* @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_channel_factory
93+
* The logger channel factory.
94+
* @param \GuzzleHttp\ClientInterface $client
95+
* The client.
96+
* @param \Drupal\commerce_price\RounderInterface $rounder
97+
* The price rounder.
98+
* @param \Drupal\commerce\TimeInterface $time
99+
* The time.
100+
* @param \Drupal\commerce_paypal\IPNHandlerInterface $ip_handler
101+
* The IPN handler.
102+
*/
103+
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, IPNHandlerInterface $ip_handler) {
55104
parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager, $payment_type_manager, $payment_method_type_manager);
105+
$this->logger = $logger_channel_factory->get('commerce_paypal');
56106
$this->httpClient = $client;
57107
$this->rounder = $rounder;
108+
$this->time = $time;
109+
$this->ipnHandler = $ip_handler;
58110
}
59111

60112
/**
@@ -68,8 +120,10 @@ public static function create(ContainerInterface $container, array $configuratio
68120
$container->get('entity_type.manager'),
69121
$container->get('plugin.manager.commerce_payment_type'),
70122
$container->get('plugin.manager.commerce_payment_method_type'),
123+
$container->get('logger.factory'),
71124
$container->get('http_client'),
72-
$container->get('commerce_price.rounder')
125+
$container->get('commerce_price.rounder'),
126+
$container->get('commerce.time')
73127
);
74128
}
75129

@@ -195,13 +249,13 @@ public function onReturn(OrderInterface $order, Request $request) {
195249
$paypal_response = $this->doExpressCheckoutDetails($order);
196250

197251
// Nothing to do for failures for now - no payment saved.
198-
// ToDo - more about the failures.
252+
// @todo - more about the failures.
199253
if ($paypal_response['PAYMENTINFO_0_PAYMENTSTATUS'] == 'Failed') {
200254
throw new PaymentGatewayException($paypal_response['PAYMENTINFO_0_LONGMESSAGE'], $paypal_response['PAYMENTINFO_0_ERRORCODE']);
201255
}
202256

203257
$payment_storage = $this->entityTypeManager->getStorage('commerce_payment');
204-
$request_time = \Drupal::service('commerce.time')->getRequestTime();
258+
$request_time = $this->time->getRequestTime();
205259
$payment = $payment_storage->create([
206260
'state' => 'authorization',
207261
'amount' => $order->getTotalPrice(),
@@ -214,7 +268,7 @@ public function onReturn(OrderInterface $order, Request $request) {
214268
]);
215269

216270
// Process payment status received.
217-
// ToDo : payment updates if needed.
271+
// @todo payment updates if needed.
218272
// If we didn't get an approval response code...
219273
switch ($paypal_response['PAYMENTINFO_0_PAYMENTSTATUS']) {
220274
case 'Voided':
@@ -349,21 +403,18 @@ public function refundPayment(PaymentInterface $payment, Price $amount = NULL) {
349403
*/
350404
public function onNotify(Request $request) {
351405
// Get IPN request data and basic processing for the IPN request.
352-
$ipn_data = $this->processIpnRequest($request);
353-
if (!$ipn_data) {
354-
return FALSE;
355-
}
406+
$ipn_data = $this->ipnHandler->process($request);
356407

357408
// Do not perform any processing on EC transactions here that do not have
358409
// transaction IDs, indicating they are non-payment IPNs such as those used
359410
// for subscription signup requests.
360411
if (empty($ipn_data['txn_id'])) {
361-
\Drupal::logger('commerce_paypal')->alert('The IPN request does not have a transaction id. Ignored.');
412+
$this->logger->alert('The IPN request does not have a transaction id. Ignored.');
362413
return FALSE;
363414
}
364415
// Exit when we don't get a payment status we recognize.
365416
if (!in_array($ipn_data['payment_status'], ['Failed', 'Voided', 'Pending', 'Completed', 'Refunded'])) {
366-
return FALSE;
417+
throw new BadRequestHttpException('Invalid payment status');
367418
}
368419
// If this is a prior authorization capture IPN...
369420
if (in_array($ipn_data['payment_status'], ['Voided', 'Pending', 'Completed']) && !empty($ipn_data['auth_id'])) {
@@ -372,7 +423,7 @@ public function onNotify(Request $request) {
372423
// If not, bail now because authorization transactions should be created
373424
// by the Express Checkout API request itself.
374425
if (!$payment) {
375-
\Drupal::logger('commerce_paypal')->warning('IPN for Order @order_number ignored: authorization transaction already created.', ['@order_number' => $ipn_data['invoice']]);
426+
$this->logger->warning('IPN for Order @order_number ignored: authorization transaction already created.', ['@order_number' => $ipn_data['invoice']]);
376427
return FALSE;
377428
}
378429
$amount = new Price($ipn_data['mc_gross'], $ipn_data['mc_currency']);
@@ -389,7 +440,7 @@ public function onNotify(Request $request) {
389440

390441
case 'Completed':
391442
$payment->state = 'capture_completed';
392-
$payment->setCapturedTime(REQUEST_TIME);
443+
$payment->setCapturedTime($this->time->getRequestTime());
393444
break;
394445
}
395446
// Update the remote id.
@@ -399,15 +450,14 @@ public function onNotify(Request $request) {
399450
// Get the corresponding parent transaction and refund it.
400451
$payment = $this->loadPaymentByRemoteId($ipn_data['parent_txn_id']);
401452
if (!$payment) {
402-
\Drupal::logger('commerce_paypal')->warning('IPN for Order @order_number ignored: the transaction to be refunded does not exist.', ['@order_number' => $ipn_data['invoice']]);
453+
$this->logger->warning('IPN for Order @order_number ignored: the transaction to be refunded does not exist.', ['@order_number' => $ipn_data['invoice']]);
403454
return FALSE;
404455
}
405456
elseif ($payment->getState() == 'capture_refunded') {
406-
\Drupal::logger('commerce_paypal')->warning('IPN for Order @order_number ignored: the transaction is already refunded.', ['@order_number' => $ipn_data['invoice']]);
457+
$this->logger->warning('IPN for Order @order_number ignored: the transaction is already refunded.', ['@order_number' => $ipn_data['invoice']]);
407458
return FALSE;
408459
}
409-
$amount_number = abs($ipn_data['mc_gross']);
410-
$amount = new Price((string) $amount_number, $ipn_data['mc_currency']);
460+
$amount = new Price((string) $ipn_data['mc_gross'], $ipn_data['mc_currency']);
411461
// Check if the Refund is partial or full.
412462
$old_refunded_amount = $payment->getRefundedAmount();
413463
$new_refunded_amount = $old_refunded_amount->add($amount);
@@ -425,7 +475,7 @@ public function onNotify(Request $request) {
425475
else {
426476
// In other circumstances, exit the processing, because we handle those
427477
// cases directly during API response processing.
428-
\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']]);
478+
$this->logger->notice('IPN for Order @order_number ignored: this operation was accommodated in the direct API response.', ['@order_number' => $ipn_data['invoice']]);
429479
return FALSE;
430480
}
431481
if (isset($payment)) {
@@ -683,4 +733,23 @@ public function doRequest(array $nvp_data) {
683733
return $paypal_response;
684734
}
685735

736+
/**
737+
* Loads the payment for a given remote id.
738+
*
739+
* @param string $remote_id
740+
* The remote id property for a payment.
741+
*
742+
* @return \Drupal\commerce_payment\Entity\PaymentInterface
743+
* Payment object.
744+
*
745+
* @todo: to be replaced by Commerce core payment storage method
746+
* @see https://www.drupal.org/node/2856209
747+
*/
748+
protected function loadPaymentByRemoteId($remote_id) {
749+
/** @var \Drupal\commerce_payment\PaymentStorage $storage */
750+
$storage = $this->entityTypeManager->getStorage('commerce_payment');
751+
$payment_by_remote_id = $storage->loadByProperties(['remote_id' => $remote_id]);
752+
return reset($payment_by_remote_id);
753+
}
754+
686755
}

0 commit comments

Comments
 (0)