Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue #2843999 by mglaman, bojanz, vasike: Implement IPN handler. #4

Open
wants to merge 11 commits into
base: 8.x-1.x
Choose a base branch
from
95 changes: 95 additions & 0 deletions src/Plugin/Commerce/PaymentGateway/ExpressCheckout.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
*/
class ExpressCheckout extends OffsitePaymentGatewayBase implements ExpressCheckoutInterface {

use PaypalPaymentGatewayTrait;

/**
* The HTTP client.
*
Expand Down Expand Up @@ -344,6 +346,99 @@ 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}
*/
Expand Down
113 changes: 113 additions & 0 deletions src/Plugin/Commerce/PaymentGateway/PaypalPaymentGatewayTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

namespace Drupal\commerce_paypal\Plugin\Commerce\PaymentGateway;

use Symfony\Component\HttpFoundation\Request;

/**
* Provides common methods to be used by PayPal payment gateways.
*/
trait PaypalPaymentGatewayTrait {

/**
* 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.
*/
public function loadPaymentByRemoteId($remote_id) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there an issue to port this to the payment storage? If so, let's make a @todo referencing it. If not, make the issue and make the @todo :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new issue and patch for Commerce
https://www.drupal.org/node/2856209
and i'll put in a comment as suggested
thanks

/** @var \Drupal\commerce_payment\PaymentStorage $storage */
$storage = \Drupal::service('entity_type.manager')->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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this method is pretty empty, let's just keep validation in here as part of the process. We can always split out later if needed. Otherwise we'd just need to call "validate".


// 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());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be in process, if we keep both methods. Process gets data, then sends it to be validated.


// 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(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';
}
}

}