commerce_gc_client-8.x-1.9/src/Controller/WebhookHandler.php
src/Controller/WebhookHandler.php
<?php
namespace Drupal\commerce_gc_client\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Component\EventDispatcher\ContainerAwareEventDispatcher;
use Drupal\commerce_order\Entity\Order;
use Drupal\commerce_price\CurrencyFormatter;
use Drupal\commerce_price\Price;
use Drupal\commerce_payment\Entity\Payment;
use Drupal\commerce_gc_client\GoCardlessPartner;
use Drupal\commerce_gc_client\Event\GoCardlessEvents;
use Drupal\commerce_gc_client\Event\WebhookEvent;
use Drupal\commerce_gc_client\Plugin\Commerce\PaymentGateway\GoCardlessClient;
use Drupal\mysql\Driver\Database\mysql\Connection;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Response;
/**
* Controller for handling webhooks from GoCardless.com.
*/
class WebhookHandler extends ControllerBase {
/**
* The database driver connection.
*
* @var \Drupal\mysql\Driver\Database\mysql\Connection
*/
protected $db;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $moduleSettings;
/**
* The event dispatcher.
*
* @var \Drupal\Component\EventDispatcher\ContainerAwareEventDispatcher
*/
protected $eventDispatcher;
/**
* The "commerce_log" entity type storage.
*
* @var \Drupal\Core\Entity\EntityTypeManager
*/
private $logStorage;
/**
* The "commerce_payment" entity type storage.
*
* @var \Drupal\Core\Entity\EntityTypeManager
*/
private $paymentStorage;
/**
* The logger.
*
* @var \Psr\Log\LoggerInterface
*/
private $logger;
/**
* The configuration settings for the GoCardless Client payment gateway.
*
* @var array
*/
private $paymentGatewaySettings;
/**
* The currency formatter service.
*
* @var Drupal\commerce_price\CurrencyFormatter
*/
protected $currencyFormatter;
/**
* The GoCardless Partner service.
*
* @var \Drupal\commerce_gc_client\GoCardlessPartner
*/
private $partner;
/**
* Constructs the WebhookHandler object.
*
* @param \Drupal\mysql\Driver\Database\mysql\Connection $connection
* The database driver connection.
* @param \Drupal\Component\EventDispatcher\ContainerAwareEventDispatcher $event_dispatcher
* The event dispatcher.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler interface.
* @param \Drupal\commerce_price\CurrencyFormatter $currency_formatter
* The Commerce currency formatter service.
* @param \Drupal\commerce_gc_clinet\GoCardlessPartner $gocardless_partner
* The GoCardless partner service.
*/
public function __construct(Connection $connection, ContainerAwareEventDispatcher $event_dispatcher, ModuleHandlerInterface $module_handler, CurrencyFormatter $currency_formatter, GoCardlessPartner $gocardless_partner) {
$this->db = $connection;
$this->eventDispatcher = $event_dispatcher;
$this->moduleSettings = $this->config('commerce_gc_client.settings')->get();
$this->logStorage = NULL;
if ($module_handler->moduleExists('commerce_log')) {
$this->logStorage = $this->entityTypeManager()->getStorage('commerce_log');
}
$this->currencyFormatter = $currency_formatter;
$this->partner = $gocardless_partner;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('database'),
$container->get('event_dispatcher'),
$container->get('module_handler'),
$container->get('commerce_price.currency_formatter'),
$container->get('commerce_gc_client.gocardless_partner')
);
}
/**
* Capture and pre-process incoming webhooks from GoCardless.
*/
public function webhook() {
$headers = function_exists('getallheaders') ? getallheaders() : $this->getallheaders();
$provided_signature = $headers['Webhook-Signature'];
$mode = strpos($headers['Origin'], 'sandbox') !== FALSE ? 'sandbox' : 'live';
if ($secret = $this->moduleSettings['webhook_secret_' . $mode]) {
$webhook = file_get_contents('php://input');
$calculated_signature = hash_hmac("sha256", $webhook, $secret);
$hash_equals = function_exists('hash_equals') ? hash_equals($provided_signature, $calculated_signature) : $this->hashEquals($provided_signature, $calculated_signature);
if ($hash_equals) {
// Process the events.
$data = json_decode($webhook, TRUE);
// Webhooks can contain multiple events, but they are not ordered
// sequentially and need to be re-ordered therefore. The following is
// an improvement but does not deal with thousandths of a second so
// some of the events may still be in the wrong order.
if (count($data['events']) > 1) {
usort($data['events'], ['Drupal\commerce_gc_client\Controller\WebhookHandler','sortByTimestamp']);
}
$payments = [];
$billing_requests = [];
foreach ($data['events'] as $event) {
switch ($event['resource_type']) {
case 'mandates':
if ($order_id = $this->getOrderId($event['links']['mandate'])) {
if ($order = Order::load($order_id)) {
$this->mandates($order, $event);
}
}
break;
case 'subscriptions':
if ($order_id = $this->getOrderId($event['links']['subscription'])) {
if ($order = Order::load($order_id)) {
$this->subscriptions($order, $event);
}
}
break;
case 'payments':
$payment = FALSE;
$payment_id = $event['links']['payment'];
// Since a webhook can include multiple events for the same
// payment, remember it to avoid having to make repeated API
// calls.
if (!isset($payments[$payment_id])) {
$result = $this->partner->api([
'mode' => $mode,
'endpoint' => 'payments',
'action' => 'get',
'id' => $payment_id,
]);
if ($result && $result->response && $result->response->status_code == 200) {
$payment = $result->response->body->payments;
$payments[$payment_id] = $payment;
}
}
else {
$payment = $payments[$payment_id];
}
if ($payment) {
// If the payment was created via a recurring order.
if (isset($payment->metadata->recurring_order_id)) {
$order_id = $payment->metadata->recurring_order_id;
}
// Else payments can be received from orders that were created
// through either a Redirect or a Billing Request flow.
elseif (!$order_id = $this->getOrderId($payment->links->mandate)) {
if (isset($payment->metadata->order_id)) {
$order_id = $payment->metadata->order_id;
}
}
if (isset($order_id) && $order = Order::load($order_id)) {
$this->payments($order, $event, $payment);
}
}
break;
case 'billing_requests':
if ($event['action'] == 'fulfilled') {
$billing_request = FALSE;
$billing_request_id = $event['links']['billing_request'];
if (!isset($billing_requests[$billing_request_id])) {
$result = $this->partner->api([
'mode' => $mode,
'endpoint' => 'billing_requests',
'action' => 'get',
'id' => $billing_request_id,
]);
if ($result && $result->response && $result->response->status_code == 200) {
$billing_request = $result->response->body->billing_requests;
$billing_requests[$billing_request_id] = $billing_request;
}
}
else {
$billing_request = $billing_requests[$billing_request_id];
}
if ($billing_request) {
if (isset($billing_request->links->mandate_request_mandate)
&& isset($billing_request->payment_request)
&& isset($billing_request->payment_request->metadata->order_id))
{
$order_id = $billing_request->payment_request->metadata->order_id;
$query = $this->db->select('commerce_gc_client', 'g')
->condition('order_id', $order_id)
->fields('g');
if (!$query->execute()->fetch()) {
$order = Order::load($order_id);
$this->billing_requests($order, $event, $billing_request);
}
}
}
}
break;
}
if (isset($order)) {
// Get payment gateway settings if not already set.
if (!isset($this->paymentGatewaySettings)) {
$payment_gateway_id = $order->payment_gateway->entity->Id();
$this->paymentGatewaySettings = $this->config('commerce_payment.commerce_payment_gateway.' . $payment_gateway_id)->get();
}
// Adds a webhook event to the Drupal log if the feature is enabled
// in the modules settings form.
if ($this->paymentGatewaySettings['configuration']['log_webhook']) {
if (!isset($this->logger)) {
$this->logger = $this->getLogger('commerce_gc_client');
}
$this->logger->notice('<pre>GoCardless webhook: <br />' . print_r($event, TRUE) . '</pre>');
}
// Dispatch an event so that other modules can respond to webhook.
!isset($resource) ? $resource = NULL : NULL;
$webhook_event = new WebhookEvent($event, $resource, $order_id);
$this->eventDispatcher->dispatch($webhook_event, GoCardlessEvents::WEBHOOK);
}
}
}
else {
$this->logger = $this->getLogger('commerce_gc_client')->warning('Webhook cannot be processed because the webhook secret is invalid.');
return new Response('Forbidden.', 403, ['Content-Type' => 'text/html']);
}
}
else {
$this->logger = $this->getLogger('commerce_gc_client')->warning('Webhook cannot be processed because the webhook secret is not set.');
return new Response('Forbidden.', 403, ['Content-Type' => 'text/html']);
}
return new Response();
}
/**
* Processes 'mandates' webhooks.
*
* @param object $order
* Commerce order entity.
* @param array $event
* The webhook event as provided by GoCardless.
*/
private function mandates($order, array $event) {
if ($this->logStorage) {
$this->logStorage->generate($order, 'webhook_description', [
'description' => $event['details']['description'],
])->save();
}
$this->db->update('commerce_gc_client')->fields([
'gc_mandate_status' => $event['action'],
])->condition('order_id', $order->id())->execute();
}
/**
* Processes 'subscription' webhooks.
*
* @param object $order
* Commerce order entity.
* @param array $event
* The webhook event as provided by GoCardless.
*/
private function subscriptions($order, array $event) {
if ($this->logStorage) {
$this->logStorage->generate($order, 'webhook_description', [
'description' => $event['details']['description'],
])->save();
}
if (!in_array($event['action'], ['payment_created', 'amended'])) {
$this->db->update('commerce_gc_client_item')->fields([
'gc_subscription_status' => $event['action'],
])->condition('gc_subscription_id', $event['links']['subscription'])->execute();
}
}
/**
* Processes 'payments' webhooks.
*
* @param object $order
* Commerce order entity.
* @param array $event
* The webhook event as provided by GoCardless.
* @param object $payment
* A GoCardless payment object that is retreived from via the API upon
* receipt of a 'payments' webhook.
*/
private function payments($order, array $event, $payment) {
if (!$this->paymentStorage) {
$this->paymentStorage = $this->entityTypeManager()->getStorage('commerce_payment');
}
$commerce_payment_id = $this->db->select('commerce_payment', 'p')
->fields('p', ['payment_id'])
->condition('remote_id', $payment->id)
->execute()->fetchField();
if ($event['action'] == 'created' && !$commerce_payment_id) {
$total_price = $order->getTotalPrice();
$currency_code = $total_price->getCurrencyCode();
if ($currency_code != $payment->currency) {
// Payment created through a Billing Request and applies to whole order
if (isset($payment->metadata->order_id)) {
$number = $total_price->getNumber();
$price = new Price((string)$number, $currency_code);
}
// Payment created through any other means
elseif (isset($payment->metadata->item_id)) {
$item = $this->entityTypeManager()->getStorage('commerce_order_item')
->load($payment->metadata->item_id);
$number = $item->getTotalPrice()->getNumber();
$price = new Price((string)$number, $currency_code);
}
if ($this->logStorage) {
$this->logStorage->generate($order, 'currency_exchanged', [
'amount' => $this->currencyFormatter->format($payment->amount / 100, $payment->currency),
])->save();
}
}
if (!isset($price)) {
$price = new Price((string) ($payment->amount / 100), $payment->currency);
}
$this->paymentStorage->create([
'state' => 'new',
'amount' => $price,
'payment_gateway' => $order->payment_gateway->entity->Id(),
'order_id' => $order->id(),
'remote_id' => $payment->id,
'remote_state' => $payment->status,
])->save();
}
elseif ($commerce_payment_id) {
$commerce_payment = Payment::load($commerce_payment_id);
$commerce_payment_state = $commerce_payment->getState()->value;
switch (TRUE) {
case in_array($event['action'], [
'pending_customer_approval',
'pending_submission',
'submitted',
]):
if ($commerce_payment_state != 'completed') {
$commerce_payment->setState('authorization');
}
break;
case in_array($event['action'], [
'confirmed',
'paid_out',
]):
$commerce_payment->setState('completed');
break;
case in_array($event['action'], [
'cancelled',
'customer_approval_denied',
'failed',
'charged_back',
]):
$commerce_payment->setState('authorization_voided');
if ($event['action'] == 'failed') {
if ($this->logStorage) {
$this->logStorage->generate($order, 'payment_failed', [
'description' => $event['details']['description'],
])->save();
}
}
break;
}
$commerce_payment->save();
}
}
/**
* Processes 'billing request' webhooks.
*
* @param object $order
* Commerce order entity.
* @param array $event
* The webhook event as provided by GoCardless.
* @param object $billing_request
* A GoCardless billing_request object that is retreived from via the API
* upon receipt of a 'billing_requests' webhook.
*/
private function billing_requests($order, array $event, $billing_request) {
// In case a mandate was requested during a billing_request_flow but was
// not created before the customer returned to the site, then finish
// processing the mandate now.
$mandate_id = $billing_request->links->mandate_request_mandate;
foreach ($order->getItems() as $item) {
$item_id = $item->id();
if ($data = $item->getData('gc')) {
if (!isset($gcid)) {
$gcid = GoCardlessClient::processMandate($order, $billing_request, $mandate_id);
}
$payment_gateway = $order->get('payment_gateway')->first()->entity;
$partner = \Drupal::service('commerce_gc_client.gocardless_partner');
$partner->setGateway($payment_gateway->id());
$calculate = commerce_gc_client_price_calculate($order, $item);
if ($gcid && $data['gc_type'] == 'P') {
$payment_request = isset($billing_request->links->payment_request) ? true : false;
GoCardlessClient::processPayment($payment_request, $partner, $item, $data, $calculate, $mandate_id, $gcid, false);
}
elseif ($gcid && $data['gc_type'] == 'S') {
GoCardlessClient::processSubscription($partner, $item, $data, $calculate, $mandate_id, $gcid, false);
}
}
}
}
/**
* Get Commerce order ID.
*
* @param string $id
* Either a GoCardless mandate ID or a GoCardless subscription ID.
*
* @return int
* The Commerce order ID for the mandate or subscription.
*/
private function getOrderId($id) {
$query = $this->db->select('commerce_gc_client', 'g');
if (substr($id, 0, 2) == 'MD') {
$query->condition('gc_mandate_id', $id);
}
else {
$query->join('commerce_gc_client_item', 'i', 'g.gcid = i.gcid');
$query->condition('gc_subscription_id', $id);
}
return $query->fields('g', ['order_id'])->execute()->fetchField();
}
/**
* Timing attack safe string comparison.
*
* Compares two strings using the same time whether they're equal or not.
* Required when hash_equals() function is not present (PHP < 5.6.0).
*
* @param string $known_string
* The string of known length to compare against.
* @param string $user_string
* The user-supplied string.
*
* @return bool
* Returns TRUE when the two strings are equal, FALSE otherwise.
*/
private function hashEquals($known_string, $user_string) {
$ret = 0;
if (strlen($known_string) !== strlen($user_string)) {
$user_string = $known_string;
$ret = 1;
}
$res = $known_string ^ $user_string;
for ($i = strlen($res) - 1; $i >= 0; --$i) {
$ret |= ord($res[$i]);
}
return !$ret;
}
/**
* Fetch all HTTP request headers.
*
* Required for Nginx servers that do not support getallheaders().
*
* @return mixed
* An associative array of all the HTTP headers in the current request, or
* FALSE on failure.
*/
private function getallheaders() {
$headers = [];
foreach ($_SERVER as $name => $value) {
if (substr($name, 0, 5) == 'HTTP_') {
$headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
}
}
return $headers;
}
/**
* usort() callback function.
*
* Sorts webhook events according to the created_at timestamp.
*/
private static function sortByTimestamp($x, $y) {
return strtotime($x['created_at']) - strtotime($y['created_at']);
}
}
