diff --git a/commerce_datatrans.routing.yml b/commerce_datatrans.routing.yml new file mode 100644 index 0000000000000000000000000000000000000000..c6ab622508a2071fe98a7c17b245c831c0c90c34 --- /dev/null +++ b/commerce_datatrans.routing.yml @@ -0,0 +1,10 @@ +commerce_datatrans.notify: + path: '/commerce/datatrans/notify' + defaults: + _controller: '\Drupal\commerce_datatrans\Controller\PaymentNotificationController::notifyPage' + requirements: + _access: 'TRUE' + options: + parameters: + commerce_payment_gateway: + type: entity:commerce_payment_gateway diff --git a/config/schema/commerce_datatrans.schema.yml b/config/schema/commerce_datatrans.schema.yml index c3326783f346f1fe9fec432781a697865cb191e2..e0476d8ed12716fa03de05e3b0a2bb7955361814 100644 --- a/config/schema/commerce_datatrans.schema.yml +++ b/config/schema/commerce_datatrans.schema.yml @@ -7,27 +7,12 @@ commerce_payment.commerce_payment_gateway.plugin.datatrans: merchant_id: type: string label: 'Merchant ID' - service_url: - type: string - label: 'Service URL' - req_type: - type: string - label: 'Request type' + auto_settle: + type: boolean + label: 'Whether to automatically settle the payment' use_alias: type: boolean label: 'Use Alias' - security_level: - type: integer - label: 'Security level' - sign: - type: string - label: 'Sign' - hmac_key: - type: string - label: 'HMAC key' - use_hmac_2: - type: boolean - label: 'Use HMAC 2 key' hmac_key_2: type: string label: 'HMAC 2 key' diff --git a/src/Controller/PaymentNotificationController.php b/src/Controller/PaymentNotificationController.php new file mode 100644 index 0000000000000000000000000000000000000000..c23c4fdc8ddde5f0fe1eefd91e6a3d12ccd8a9c9 --- /dev/null +++ b/src/Controller/PaymentNotificationController.php @@ -0,0 +1,133 @@ +<?php + +namespace Drupal\commerce_datatrans\Controller; + +use Drupal\commerce_datatrans\DatatransHelper; +use Drupal\commerce_datatrans\Plugin\Commerce\PaymentGateway\Datatrans; +use Drupal\commerce_order\Entity\OrderInterface; +use Drupal\commerce_payment\PaymentOrderUpdaterInterface; +use Drupal\Component\Serialization\Json; +use Drupal\Core\Controller\ControllerBase; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +/** + * Provides the endpoint for payment notifications. + */ +class PaymentNotificationController extends ControllerBase { + + /** + * The payment order updater. + * + * @var \Drupal\commerce_payment\PaymentOrderUpdaterInterface + */ + protected $paymentOrderUpdater; + + public function __construct(PaymentOrderUpdaterInterface $payment_order_updater) { + $this->paymentOrderUpdater = $payment_order_updater; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static($container->get('commerce_payment.order_updater')); + } + + /** + * Provides the datatrans webhook. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request. + * + * @return \Symfony\Component\HttpFoundation\Response + * The response. + */ + public function notifyPage(Request $request) { + + $content = (string) $request->getContent(); + if (!$content) { + throw new NotFoundHttpException(); + } + + $transaction_data = Json::decode($content); + if (!$transaction_data || empty($transaction_data['refno'])) { + throw new NotFoundHttpException(); + } + + $order_id = $transaction_data['refno']; + $order = $this->entityTypeManager()->getStorage('commerce_order')->load($order_id); + if (!$order instanceof OrderInterface) { + throw new NotFoundHttpException(); + } + + $payment_storage = $this->entityTypeManager->getStorage('commerce_payment'); + + // If there is an existing payment for this transaction, use its payment + // gateway. + $payments = $payment_storage->loadByProperties([ + 'remote_id' => $transaction_data['transactionId'], + 'order_id' => $order->id(), + ]); + /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */ + $payment = $payments ? reset($payments) : NULL; + if ($payment) { + $gateway = $payment->getPaymentGateway(); + } + else { + $gateway = $order->get('payment_gateway')->entity; + } + + if (!$gateway || !$gateway->getPlugin() instanceof Datatrans) { + throw new NotFoundHttpException(); + } + + /** @var \Drupal\commerce_datatrans\Plugin\Commerce\PaymentGateway\Datatrans $gateway_plugin */ + $gateway_plugin = $gateway->getPlugin(); + + $sign2_hmac_key = $gateway_plugin->getConfiguration()['hmac_key_2']; + if (!$sign2_hmac_key) { + throw new AccessDeniedHttpException('The webhook is only supported when sign2 is configured.'); + } + + // If the sign2 HMAC key is configured, use it to verify the data. + $is_valid = FALSE; + if ($signature_header = $request->headers->get('Datatrans-Signature')) { + if (preg_match('/t=(\d+),s0=([a-z0-9]+)/', $signature_header, $match)) { + $timestamp = $match[1]; + $sign = $match[2]; + + $is_valid = $sign == DatatransHelper::generateSign($sign2_hmac_key, $timestamp, $content); + } + } + else { + $this->getLogger('commerce_datatrans')->warning('Received datatrans webhook request without Datatrans-Signature header.'); + throw new AccessDeniedHttpException(); + } + + if (!$is_valid) { + $this->getLogger('commerce_datatrans')->warning('Received datatrans webhook request with invalid signature.'); + throw new AccessDeniedHttpException(); + } + + $payment = $gateway_plugin->processPayment($transaction_data, $order); + + if ($payment) { + + // Immediately trigger the payment updater, as doing that during + // the kernel destruct event might result in race conditions with + // the user returning from Datatrans. + $this->paymentOrderUpdater->updateOrders(); + + $this->getLogger('commerce_datatrans')->notice('Processed datatrans webhook for order @order_id, payment created.', [ + '@order_id' => $order->id(), + ]); + } + + return new Response('', 201); + } + +} diff --git a/src/DatatransHelper.php b/src/DatatransHelper.php index 99965f71c22f619b1353bb2f9edf54b98df750ce..d96e84f6eae9bea65bc51679250cbf99d77e4ee3 100644 --- a/src/DatatransHelper.php +++ b/src/DatatransHelper.php @@ -28,8 +28,8 @@ class DatatransHelper { * @return string * The computed hash. */ - public static function generateSign($hmac_key, $merchant_id, $amount, $currency, $reference_number) { - $hmac_data = $merchant_id . $amount . $currency . $reference_number; + public static function generateSign($hmac_key, $timestamp, $content) { + $hmac_data = $timestamp . $content; return hash_hmac('sha256', $hmac_data, pack('H*', $hmac_key)); } diff --git a/src/PaymentInitializeResponse.php b/src/PaymentInitializeResponse.php new file mode 100644 index 0000000000000000000000000000000000000000..f76f3a36fc8cef0bc27211182a217f9c1506f98f --- /dev/null +++ b/src/PaymentInitializeResponse.php @@ -0,0 +1,66 @@ +<?php + +namespace Drupal\commerce_datatrans; + +/** + * Represents a response for a initialize payment request. + */ +class PaymentInitializeResponse { + + /** + * The redirect location URL. + * + * @var string + */ + protected $location; + + /** + * The return response data. + * + * @var array + */ + protected $response; + + /** + * PaymentInitializeResponse constructor. + * + * @param string $location + * The redirect location URL. + * @param array $response + * The return response data. + */ + public function __construct(string $location, array $response) { + $this->location = $location; + $this->response = $response; + } + + /** + * Returns the redirect location URL. + * + * @return string + * The redirect location URL. + */ + public function getLocation(): string { + return $this->location; + } + + /** + * Returns the transaction ID. + * @return int + * The transaction ID. + */ + public function getTransactionId(): int { + return $this->response['transactionId']; + } + + /** + * Returns the transaction ID. + * + * @return array + * The transaction ID. + */ + public function getResponse(): array { + return $this->response; + } + +} diff --git a/src/Plugin/Commerce/PaymentGateway/Datatrans.php b/src/Plugin/Commerce/PaymentGateway/Datatrans.php index 4d26b45cf7025380058cc09db5a982a8983fab2f..b7ab62694a8595b50a36ec32aa322b014f587088 100644 --- a/src/Plugin/Commerce/PaymentGateway/Datatrans.php +++ b/src/Plugin/Commerce/PaymentGateway/Datatrans.php @@ -2,30 +2,13 @@ namespace Drupal\commerce_datatrans\Plugin\Commerce\PaymentGateway; -use Drupal\commerce_datatrans\DatatransHelper; use Drupal\commerce_order\Entity\OrderInterface; -use Drupal\commerce_payment\CreditCard; -use Drupal\commerce_payment\Entity\PaymentInterface; -use Drupal\commerce_payment\Entity\PaymentMethod; -use Drupal\commerce_payment\Entity\PaymentMethodInterface; 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_payment\Plugin\Commerce\PaymentGateway\OffsitePaymentGatewayInterface; use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsRefundsInterface; -use Drupal\commerce_price\Price; -use Drupal\Component\Datetime\TimeInterface; use Drupal\Component\Serialization\Json; -use Drupal\Core\Entity\EntityTypeManagerInterface; -use Drupal\Core\Form\FormStateInterface; -use Drupal\Core\Logger\LoggerChannelFactoryInterface; -use Drupal\Core\Render\Markup; use Drupal\Core\Url; -use GuzzleHttp\ClientInterface; -use GuzzleHttp\Exception\ClientException; -use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; /** * Provides the Datatrans payment gateway. @@ -46,504 +29,43 @@ use Symfony\Component\HttpFoundation\Response; * }, * ) */ -class Datatrans extends OffsitePaymentGatewayBase implements SupportsRefundsInterface { - - /** - * Logger. - * - * @var \Drupal\Core\Logger\LoggerChannelInterface - */ - protected $logger; - - /** - * The HTTP client. - * - * @var \GuzzleHttp\ClientInterface - */ - protected $httpClient; - - /** - * Constructs a Datatrans 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\Component\Datetime\TimeInterface $time - * The time. - * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory - * The logger factory service. - */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, PaymentTypeManager $payment_type_manager, PaymentMethodTypeManager $payment_method_type_manager, TimeInterface $time, LoggerChannelFactoryInterface $logger_factory, ClientInterface $http_client) { - parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager, $payment_type_manager, $payment_method_type_manager, $time); - $this->logger = $logger_factory->get('commerce_datatrans'); - $this->httpClient = $http_client; - } - - /** - * {@inheritdoc} - */ - public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { - return new static( - $configuration, - $plugin_id, - $plugin_definition, - $container->get('entity_type.manager'), - $container->get('plugin.manager.commerce_payment_type'), - $container->get('plugin.manager.commerce_payment_method_type'), - $container->get('datetime.time'), - $container->get('logger.factory'), - $container->get('http_client') - ); - } - - /** - * {@inheritdoc} - */ - public function defaultConfiguration() { - return [ - 'merchant_id' => '', - 'service_url' => 'https://pay.sandbox.datatrans.com/upp/jsp/upStart.jsp', - 'req_type' => 'CAA', - 'use_alias' => FALSE, - 'security_level' => 2, - 'sign' => '', - 'hmac_key' => '', - 'use_hmac_2' => FALSE, - 'hmac_key_2' => '', - 'password' => '', - ] + parent::defaultConfiguration(); - } - - /** - * {@inheritdoc} - */ - public function buildConfigurationForm(array $form, FormStateInterface $form_state) { - $form = parent::buildConfigurationForm($form, $form_state); - - $form['merchant_id'] = [ - '#type' => 'textfield', - '#title' => t('Merchant-ID'), - '#default_value' => $this->configuration['merchant_id'], - '#required' => TRUE, - ]; - - $form['service_url'] = [ - '#type' => 'textfield', - '#title' => t('Service URL'), - '#default_value' => $this->configuration['service_url'], - '#required' => TRUE, - ]; - - $form['req_type'] = [ - '#type' => 'select', - '#title' => t('Request Type'), - '#options' => [ - 'NOA' => t('Authorization only'), - 'CAA' => t('Authorization with immediate settlement'), - 'ignore' => t('According to the setting in the Web Admin Tool'), - ], - '#default_value' => $this->configuration['req_type'], - ]; - - $form['use_alias'] = [ - '#type' => 'checkbox', - '#title' => 'Use Alias', - '#default_value' => $this->configuration['use_alias'], - '#description' => t('Enable this option to always request an alias from datatrans. This is used for recurring payments and should be disabled if not necessary. If the response does not provide an alias, the payment will not be settled (or refunded, in case it was settled immediately) and the payment needs to be repeated.'), - ]; - - $url = Url::fromUri('https://pilot.datatrans.biz/showcase/doc/Technical_Implementation_Guide.pdf', ['external' => TRUE])->toString(); - $form['security'] = [ - '#type' => 'fieldset', - '#title' => t('Security Settings'), - '#collapsible' => FALSE, - '#collapsed' => FALSE, - '#description' => t('You should not work with anything else than security level 2 on a productive system. Without the HMAC key there is no way to check whether the data really comes from Datatrans. You can find more details about the security levels in your Datatrans account at UPP ADMINISTRATION -> Security. Or check the technical information in the <a href=":url">Technical_Implementation_Guide</a>', [':url' => $url]), - ]; - - $form['security']['security_level'] = [ - '#type' => 'select', - '#title' => t('Security Level'), - '#options' => [ - '0' => t('Level 0. No additional security element will be send with payment messages. (not recommended)'), - '1' => t('Level 1. An additional Merchant-Identification will be send with payment messages'), - '2' => t('Level 2. Important parameters will be digitally signed (HMAC-SHA256) and sent with payment messages'), - ], - '#default_value' => $this->configuration['security_level'], - ]; - - $form['security']['sign'] = [ - '#type' => 'textfield', - '#title' => t('Merchant control sign'), - '#default_value' => $this->configuration['sign'], - '#description' => t('Used for security level 1'), - '#states' => [ - 'visible' => [ - ':input[name="configuration[datatrans][security][security_level]"]' => ['value' => '1'], - ], - ], - ]; - - $form['security']['hmac_key'] = [ - '#type' => 'textfield', - '#title' => $this->t('HMAC Key'), - '#default_value' => $this->configuration['hmac_key'], - '#description' => t('Used for security level 2'), - '#states' => [ - 'visible' => [ - ':input[name="configuration[datatrans][security][security_level]"]' => ['value' => '2'], - ], - ], - ]; - - $form['security']['use_hmac_2'] = array( - '#type' => 'checkbox', - '#title' => $this->t('Use HMAC 2'), - '#default_value' => $this->configuration['use_hmac_2'], - '#states' => array( - 'visible' => array( - ':input[name="configuration[datatrans][security][security_level]"]' => ['value' => '2'], - ), - ), - ); - - $form['security']['hmac_key_2'] = array( - '#type' => 'textfield', - '#title' => $this->t('HMAC Key 2'), - '#default_value' => $this->configuration['hmac_key_2'], - '#states' => array( - 'visible' => array( - ':input[name="configuration[datatrans][security][security_level]"]' => ['value' => '2'], - ), - ), - ); - - $form['security']['password'] = array( - '#type' => 'textfield', - '#title' => $this->t('Password'), - '#description' => $this->t('Required for Server to server API requests like refund.'), - '#default_value' => $this->configuration['password'], - ); - - return $form; - } - - /** - * {@inheritdoc} - */ - public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { - parent::submitConfigurationForm($form, $form_state); - if (!$form_state->getErrors()) { - $values = $form_state->getValue($form['#parents']); - $this->configuration['merchant_id'] = $values['merchant_id']; - $this->configuration['service_url'] = $values['service_url']; - $this->configuration['req_type'] = $values['req_type']; - $this->configuration['use_alias'] = $values['use_alias']; - $this->configuration['security_level'] = $values['security']['security_level']; - $this->configuration['sign'] = $values['security']['sign']; - $this->configuration['hmac_key'] = $values['security']['hmac_key']; - $this->configuration['use_hmac_2'] = $values['security']['use_hmac_2']; - $this->configuration['hmac_key_2'] = $values['security']['hmac_key_2']; - $this->configuration['password'] = $values['security']['password']; - } - } +class Datatrans extends DatatransBase implements SupportsRefundsInterface, OffsitePaymentGatewayInterface { /** * {@inheritdoc} */ public function onReturn(OrderInterface $order, Request $request) { - // @todo Add examples of request validation. - $post_data = $request->request->all(); - - if (!$this->validateResponseData($post_data, $order)) { - $this->messenger()->addWarning($this->t('There was a problem while processing your payment.')); - throw new PaymentGatewayException(); - } - - $this->processPayment($post_data, $order); - } - - /** - * {@inheritdoc} - */ - public function onNotify(Request $request) { - $post_data = $request->request->all(); - - /** @var \Drupal\commerce_order\entity\OrderInterface $order */ - $order = $this->entityTypeManager->getStorage('commerce_order')->load($post_data['refno']); - if (!$order) { - return new Response('', 400); - } - - if ($this->validateResponseData($post_data, $order)) { - $this->processPayment($post_data, $order); - } - else { - return new Response('', 400); - } - } - - /** - * Validate the data received from Datatrans. - * - * @param array $post_data - * Data received from Datatrans. - * @param \Drupal\commerce_order\Entity\OrderInterface $order - * Order entity. - * - * @return bool - * The validation result. - */ - protected function validateResponseData(array $post_data, OrderInterface $order) { - $gateway_config = $this->getConfiguration(); + $transaction_id = $request->query->get('datatransTrxId'); - // We must have post data, order id and this order must exist. - if (empty($post_data) || empty($post_data['refno'])) { - return FALSE; - } - - // Error and cancel. - if ($post_data['status'] == 'error') { - $this->logger->error('The payment gateway returned the error code %code (%code_text) with details %details for order %order_id', [ - '%code' => $post_data['errorCode'], - '%code_text' => DatatransHelper::mapErrorCode($post_data['errorCode']), - '%details' => $post_data['errorDetail'], - '%order_id' => $order->id(), - ]); - return FALSE; - } - - if ($post_data['status'] == 'cancel') { - $this->logger->info('The user canceled the authorisation process for order %order_id', [ - '%order_id' => $order->id(), - ]); - return FALSE; - } - - // Security levels. - // @todo Does this really need to be submitted/verified? - if (empty($post_data['security_level']) || $post_data['security_level'] != $gateway_config['security_level']) { - return FALSE; - } - - // If security level 2 is configured then generate and use a sign. - if ($gateway_config['security_level'] == 2) { - // If a second hmac key is configured then use that to sign. - $key = $gateway_config['use_hmac_2'] ? $gateway_config['hmac_key_2'] : $gateway_config['hmac_key']; - $sign2 = DatatransHelper::generateSign($key, $gateway_config['merchant_id'], $post_data['amount'], $post_data['currency'], $post_data['uppTransactionId']); - - // Check for correct sign. - if (empty($post_data['sign2']) || $sign2 != $post_data['sign2']) { - $this->logger->warning('Detected non matching signs while processing order %order_id.', [ - '%order_id' => $order->id(), - ]); - return FALSE; - } - } - - if ($post_data['status'] == 'success') { - return TRUE; - } - - return FALSE; - } - - /** - * Process the payment. - * - * @param array $post_data - * Array with data received from Datatrans. - * @param \Drupal\commerce_order\Entity\OrderInterface $order - * The order entity. - * - * @return \Drupal\Core\Entity\EntityInterface|bool - * The payment entity or boolean false if the payment with - * this authorisation code was already processed. - */ - protected function processPayment(array $post_data, OrderInterface $order) { - $payment_storage = $this->entityTypeManager->getStorage('commerce_payment'); - - if ($payment_storage->loadByProperties(['remote_id' => $post_data['uppTransactionId']])) { - return FALSE; - } - - /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */ - $payment = $payment_storage->create([ - 'state' => 'authorization', - 'amount' => $order->getTotalPrice(), - 'payment_gateway' => $this->parentEntity->id(), - 'order_id' => $order->id(), - 'test' => $this->getMode() == 'test', - 'remote_id' => $post_data['uppTransactionId'], - 'remote_state' => $post_data['responseMessage'], - 'authorized' => $this->time->getRequestTime(), - ]); - $payment->save(); - - if ($this->shouldCompletePayment($post_data['reqtype'], $payment)) { - $this->completePayment($payment); - } - - // Create a payment method if we use alias. - if (isset($post_data['useAlias']) && $post_data['useAlias'] === 'true') { - $payment_method = $this->createPaymentMethod($post_data); - $order->set('payment_method', $payment_method); - $order->save(); - } - - return $payment; - } - - - /** - * Create an alias payment method. - * - * @todo https://www.drupal.org/node/2838380 - * - * @param array $payment_details - * Array of payment details we get from Datatrans. - * - * @return \Drupal\commerce_payment\Entity\PaymentMethodInterface - * The created payment method. - */ - public function createPaymentMethod(array $payment_details) { - $payment_method = PaymentMethod::create([ - 'payment_gateway' => $this->pluginId, - 'type' => 'datatrans_alias', - 'reusable' => TRUE, - 'pmethod' => $payment_details['pmethod'], - 'masked_cc' => $payment_details['maskedCC'], - 'expm' => $payment_details['expm'], - 'expy' => $payment_details['expy'], - ]); + // This is expected to run after the webhook has been completed if + // configured. + // @todo: Test if a lock is necessary to prevent race conditions. + try { + $response = $this->doRequest('transactions/' . $transaction_id, [], 'GET'); + $transaction_data = Json::decode((string) $response->getBody()); - $payment_method->setRemoteId($payment_details['aliasCC']); - if (!empty($payment_details['expm']) && !empty($payment_details['expy'])) { - $expires = CreditCard::calculateExpirationTimestamp($payment_details['expm'], $payment_details['expy']); - $payment_method->setExpiresTime($expires); + $this->processPayment($transaction_data, $order); } - $payment_method->save(); - return $payment_method; - } - - /** - * Delete an alias payment method. - * - * @todo https://www.drupal.org/node/2838380 - * - * @param \Drupal\commerce_payment\Entity\PaymentMethodInterface $payment_method - */ - public function deletePaymentMethod(PaymentMethodInterface $payment_method) { - // Delete the remote record here, throw an exception if it fails. - // See \Drupal\commerce_payment\Exception for the available exceptions. - // Delete the local entity. - $payment_method->delete(); - } - - /** - * Checks if payment should be completed. - * - * @param string $reqtype - * The request type. - * @param \Drupal\commerce_payment\Entity\PaymentInterface $payment - * The payment entity. - * - * @return bool - * TRUE if the payment should be marked as completed. - */ - protected function shouldCompletePayment(string $reqtype, PaymentInterface $payment): bool { - if ($reqtype === 'CAA' && $payment->getState()->value === 'authorization') { - return TRUE; + catch (\Exception $e) { + \watchdog_exception('commerce_datatrans', $e); + throw new PaymentGatewayException($this->t('There was a problem while processing your payment.')); } - - return FALSE; } /** - * Completes a payment. - * - * @param \Drupal\commerce_payment\Entity\PaymentInterface $payment - * The payment entity. + * {@inheritdoc} */ - protected function completePayment(PaymentInterface $payment) { - $payment->setState('completed'); - $payment->save(); + public function getNotifyUrl() { + return Url::fromRoute('commerce_datatrans.notify', [], ['absolute' => TRUE]); } /** * {@inheritdoc} */ - public function refundPayment(PaymentInterface $payment, Price $amount = NULL) { - $this->assertPaymentState($payment, ['completed', 'partially_refunded']); - // If not specified, refund the entire amount. - $amount = $amount ?: $payment->getAmount(); - $this->assertRefundAmount($payment, $amount); - - $data = [ - 'amount' => $this->toMinorUnits($amount), - 'currency' => $amount->getCurrencyCode(), - 'refno' => $payment->getOrderId(), - ]; - - $hostname = parse_url($this->configuration['service_url'], PHP_URL_HOST); - // @todo: Add an explicit setting for this? - $hostname = \str_replace('pay.', 'api.', $hostname); - if (empty($hostname) || empty($this->configuration['password'])) { - throw new PaymentGatewayException($this->t('Invalid configuration, ensure that there is a valid URL and password.')); - } - $url = 'https://' . $hostname . '/v1/transactions/' . $payment->getRemoteId() . '/credit'; - - $options = [ - 'allow_redirects' => FALSE, - 'auth' => [$this->configuration['merchant_id'], $this->configuration['password']], - 'json' => $data, - 'headers' => [ - 'Content-Type' => 'application/json' - ] - ]; - - try { - $response = $this->httpClient->request('POST', $url, $options); - } - catch (ClientException $e) { - - $response = $e->getResponse(); - $body = Json::decode((string) $response->getBody()); - - $arguments = [ - '@code' => $body['error']['code'], - '@error' => $body['error']['message'], - ]; - throw new PaymentGatewayException($this->t('Refund failed with code @code: @error', $arguments), 0, $e); - } - catch (\Exception $e) { - throw new PaymentGatewayException($this->t('Refund failed: @error.', ['@error' => $e->getMessage()]), 0, $e); - } - - if ($response->getStatusCode() != 200) { - throw new PaymentGatewayException($this->t('Refund failed with unexpected response: @response', ['@response' => (string) $response->getBody()])); - } - - $old_refunded_amount = $payment->getRefundedAmount(); - $new_refunded_amount = $old_refunded_amount->add($amount); - if ($new_refunded_amount->lessThan($payment->getAmount())) { - $payment->state = 'partially_refunded'; - } - else { - $payment->state = 'refunded'; - } - - $payment->setRefundedAmount($new_refunded_amount); - $payment->save(); + public function onCancel(OrderInterface $order, Request $request) { + $this->messenger()->addMessage($this->t('You have canceled checkout at @gateway but may resume the checkout process here when you are ready.', [ + '@gateway' => $this->getDisplayLabel(), + ])); } } diff --git a/src/Plugin/Commerce/PaymentGateway/DatatransBase.php b/src/Plugin/Commerce/PaymentGateway/DatatransBase.php new file mode 100644 index 0000000000000000000000000000000000000000..1211031ad9527018f5eb4e682369f1c311b663ca --- /dev/null +++ b/src/Plugin/Commerce/PaymentGateway/DatatransBase.php @@ -0,0 +1,394 @@ +<?php + +namespace Drupal\commerce_datatrans\Plugin\Commerce\PaymentGateway; + +use Drupal\commerce_datatrans\PaymentInitializeResponse; +use Drupal\commerce_order\Entity\OrderInterface; +use Drupal\commerce_payment\CreditCard; +use Drupal\commerce_payment\Entity\PaymentInterface; +use Drupal\commerce_payment\Entity\PaymentMethod; +use Drupal\commerce_payment\Entity\PaymentMethodInterface; +use Drupal\commerce_payment\Exception\PaymentGatewayException; +use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\PaymentGatewayBase; +use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsRefundsInterface; +use Drupal\commerce_price\Price; +use Drupal\Component\Serialization\Json; +use Drupal\Component\Utility\NestedArray; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Url; +use GuzzleHttp\Exception\ClientException; +use Psr\Http\Message\ResponseInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +/** + * Provides the base class of the Datatrans payment gateway. + */ +class DatatransBase extends PaymentGatewayBase implements SupportsRefundsInterface { + + /** + * The HTTP client. + * + * @var \GuzzleHttp\ClientInterface + */ + protected $httpClient; + + /** + * The module handler. + * + * @var \Drupal\Core\Extension\ModuleHandlerInterface + */ + protected $moduleHandler; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + $gateway = parent::create($container, $configuration, $plugin_id, $plugin_definition); + $gateway->httpClient = $container->get('http_client'); + $gateway->moduleHandler = $container->get('module_handler'); + return $gateway; + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return [ + 'merchant_id' => '', + 'auto_settle' => TRUE, + 'use_alias' => FALSE, + 'hmac_key_2' => '', + 'password' => '', + ] + parent::defaultConfiguration(); + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + + $form['merchant_id'] = [ + '#type' => 'textfield', + '#title' => t('Merchant-ID'), + '#default_value' => $this->configuration['merchant_id'], + '#required' => TRUE, + ]; + + $form['password'] = array( + '#type' => 'textfield', + '#title' => $this->t('Password'), + '#description' => $this->t('The password can be set under Server-to-Server UPP security settings'), + '#required' => TRUE, + '#default_value' => $this->configuration['password'], + ); + + $form['hmac_key_2'] = array( + '#type' => 'textfield', + '#title' => $this->t('Webhook Sign Key (sign2)'), + '#description' => $this->t('Required when using webhooks. Note that the webhook (URL POST) must be configured in the Datatrans Backend and only a single, static URL is supported per merchant. Webhook URL: @webhook_url.', [ + '@webhook_url' => Url::fromRoute('commerce_datatrans.notify')->setAbsolute()->toString(), + ]), + '#default_value' => $this->configuration['hmac_key_2'], + ); + + $form['auto_settle'] = [ + '#type' => 'checkbox', + '#title' => t('Automatically settle the payment'), + '#default_value' => $this->configuration['auto_settle'], + ]; + + $form['use_alias'] = [ + '#type' => 'checkbox', + '#title' => 'Use Alias', + '#default_value' => $this->configuration['use_alias'], + '#description' => t('Enable this option to always request an alias from datatrans. This is used for recurring payments and should be disabled if not necessary. If the response does not provide an alias, the payment will not be settled (or refunded, in case it was settled immediately) and the payment needs to be repeated.'), + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + parent::submitConfigurationForm($form, $form_state); + if (!$form_state->getErrors()) { + $values = $form_state->getValue($form['#parents']); + $this->configuration['merchant_id'] = $values['merchant_id']; + $this->configuration['auto_settle'] = $values['auto_settle']; + $this->configuration['use_alias'] = $values['use_alias']; + $this->configuration['hmac_key_2'] = $values['hmac_key_2']; + $this->configuration['password'] = $values['password']; + } + } + + /** + * {@inheritdoc} + */ + public function onNotify(Request $request) { + return new Response('The default notification callback is not supported.', 400); + } + + /** + * Process the payment. + * + * @param array $transaction_data + * Transaction status data. + * @param \Drupal\commerce_order\Entity\OrderInterface $order + * The order entity. + * + * @return \Drupal\Core\Entity\EntityInterface|bool + * The payment entity or boolean false if the payment with + * this authorisation code was already processed. + */ + public function processPayment(array $transaction_data, OrderInterface $order) { + $payment_storage = $this->entityTypeManager->getStorage('commerce_payment'); + + // If there is an existing payment that is not in pending state, + // this transaction has already been processed, abort. + $payments = $payment_storage->loadByProperties([ + 'remote_id' => $transaction_data['transactionId'], + 'payment_gateway' => $this->parentEntity->id(), + ]); + /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */ + $payment = $payments ? reset($payments) : NULL; + if ($payment && $payment->getState()->value != 'pending') { + return FALSE; + } + + if (!$payment) { + // Create a payment if there is none yet. + // @todo: Support non-complete payment amount for new payment? + $payment = $payment_storage->create([ + 'amount' => $order->getTotalPrice(), + 'payment_gateway' => $this->parentEntity->id(), + 'order_id' => $order->id(), + 'remote_id' => $transaction_data['transactionId'], + ]); + } + + $payment->setState($transaction_data['status'] == 'settled' ? 'completed' : 'authorization'); + $payment->setRemoteState($transaction_data['status']); + if (!$payment->getAuthorizedTime()) { + $payment->setAuthorizedTime($this->time->getRequestTime()); + } + if ($transaction_data['status'] == 'settled') { + $payment->setCompletedTime($this->time->getRequestTime()); + } + + /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */ + $payment->save(); + + // Create a payment method if we use alias. + if (!empty($transaction_data['card']['alias'])) { + $payment_method = $this->createPaymentMethod($transaction_data); + $order->set('payment_method', $payment_method); + $order->save(); + } + + return $payment; + } + + + /** + * Create an alias payment method. + * + * @todo https://www.drupal.org/node/2838380 + * + * @param array $transaction_data + * Transaction status data. + * + * @return \Drupal\commerce_payment\Entity\PaymentMethodInterface + * The created payment method. + */ + public function createPaymentMethod(array $transaction_data) { + $payment_method = PaymentMethod::create([ + 'payment_gateway' => $this->parentEntity->id(), + 'type' => 'datatrans_alias', + 'reusable' => TRUE, + 'pmethod' => $transaction_data['pmethod'], + 'masked_cc' => $transaction_data['card']['masked'], + ]); + + $payment_method->setRemoteId($transaction_data['card']['alias']); + if (!empty($transaction_data['card']['expiryMonth']) && !empty($transaction_data['card']['expiryYear'])) { + // @todo remove custom fields? + $payment_method->set('expm', $transaction_data['card']['expiryMonth']); + $payment_method->set('expm', $transaction_data['card']['expiryMonth']); + + $expires = CreditCard::calculateExpirationTimestamp($transaction_data['card']['expiryMonth'], $transaction_data['card']['expiryYear']); + $payment_method->setExpiresTime($expires); + } + $payment_method->save(); + return $payment_method; + } + + /** + * Delete an alias payment method. + * + * @todo https://www.drupal.org/node/2838380 + * + * @param \Drupal\commerce_payment\Entity\PaymentMethodInterface $payment_method + */ + public function deletePaymentMethod(PaymentMethodInterface $payment_method) { + // Delete the remote record here, throw an exception if it fails. + // See \Drupal\commerce_payment\Exception for the available exceptions. + // Delete the local entity. + $payment_method->delete(); + } + + /** + * {@inheritdoc} + */ + public function refundPayment(PaymentInterface $payment, Price $amount = NULL) { + $this->assertPaymentState($payment, ['completed', 'partially_refunded']); + // If not specified, refund the entire amount. + $amount = $amount ?: $payment->getAmount(); + $this->assertRefundAmount($payment, $amount); + + $data = [ + 'amount' => $this->toMinorUnits($amount), + 'currency' => $amount->getCurrencyCode(), + 'refno' => $payment->getOrderId(), + ]; + + $url = 'transactions/' . $payment->getRemoteId() . '/credit'; + + try { + $response = $this->doRequest($url, $data); + } + catch (ClientException $e) { + + $response = $e->getResponse(); + $body = Json::decode((string) $response->getBody()); + + $arguments = [ + '@code' => $body['error']['code'], + '@error' => $body['error']['message'], + ]; + throw new PaymentGatewayException($this->t('Refund failed with code @code: @error', $arguments), 0, $e); + } + catch (\Exception $e) { + throw new PaymentGatewayException($this->t('Refund failed: @error.', ['@error' => $e->getMessage()]), 0, $e); + } + + if ($response->getStatusCode() != 200) { + throw new PaymentGatewayException($this->t('Refund failed with unexpected response: @response', ['@response' => (string) $response->getBody()])); + } + + $old_refunded_amount = $payment->getRefundedAmount(); + $new_refunded_amount = $old_refunded_amount->add($amount); + if ($new_refunded_amount->lessThan($payment->getAmount())) { + $payment->state = 'partially_refunded'; + } + else { + $payment->state = 'refunded'; + } + + $payment->setRefundedAmount($new_refunded_amount); + $payment->save(); + } + + /** + * Initializes a Payment based on the given payment and additional data. + * + * @param \Drupal\commerce_payment\Entity\PaymentInterface $payment + * The payment that must be paid. + * @param array $data + * Any additional data to be sent with the request. + * + * @return \Drupal\commerce_datatrans\PaymentInitializeResponse + * The transaction information if the initialization was successful. + * + * @throws \GuzzleHttp\Exception\GuzzleException + * A HTTP exception with the response from datatrans in case of an error. + */ + public function initializePayment(PaymentInterface $payment, array $data = []): PaymentInitializeResponse { + $order = $payment->getOrder(); + + // Calculate the amount in the form Datatrans expects it. + $amount = $this->toMinorUnits($payment->getAmount()); + + $data = NestedArray::mergeDeep($data, [ + 'currency' => $payment->getAmount()->getCurrencyCode(), + 'refno' => $order->id(), + 'autoSettle' => (bool) $this->configuration['auto_settle'], + 'amount' => (int) $amount, + ]); + + // If use alias option was enabled in method configuration apply this for + // this payment method plugin. + if ($this->configuration['use_alias']) { + $data['options']['createAlias'] = true; + $data['options']['returnMaskedCardNumber'] = true; + } + + $this->moduleHandler->alter('commerce_datatrans_initialize_payment', $data, $payment, $this); + + try { + $response = $this->doRequest('transactions', $data); + } + catch (ClientException $e) { + + $response = $e->getResponse(); + $body = Json::decode((string) $response->getBody()); + + $arguments = [ + '@code' => $body['error']['code'], + '@error' => $body['error']['message'], + ]; + throw new PaymentGatewayException($this->t('Payment initialize failed with code @code: @error', $arguments), 0, $e); + } + catch (\Exception $e) { + throw new PaymentGatewayException($this->t('Payment initialize failed: @error.', ['@error' => $e->getMessage()]), 0, $e); + } + + $body = Json::decode((string) $response->getBody()); + + return new PaymentInitializeResponse($response->getHeaderLine('Location'), $body); + } + + /** + * Executes an API request and returns the response. + * + * @param string $url + * The API path without a leading slash. + * @param array $data + * The data to be sent as JSON. + * @param string $method + * The HTTP method, defaults to POST. + * + * @return \Psr\Http\Message\ResponseInterface + * The API response. + * + * @throws \GuzzleHttp\Exception\GuzzleException + * Thrown in case of an authentication error or invalid request. + */ + protected function doRequest(string $url, array $data, string $method = 'POST'): ResponseInterface { + if ($this->getMode() == 'test') { + $hostname = 'api.sandbox.datatrans.com'; + } + else { + $hostname = 'api.datatrans.com'; + } + $url = 'https://' . $hostname . '/v1/' . $url; + + $options = [ + 'allow_redirects' => FALSE, + 'auth' => [ + $this->configuration['merchant_id'], + $this->configuration['password'] + ], + 'json' => $data, + 'headers' => [ + 'Content-Type' => 'application/json' + ] + ]; + + return $this->httpClient->request($method, $url, $options); + } + +} diff --git a/src/PluginForm/DatatransForm.php b/src/PluginForm/DatatransForm.php index 8edfb2d32cadd8732fb01c2792c6a4d67ef70913..159225baeeaadf8f3d7321b390fdf298c7f3b099 100644 --- a/src/PluginForm/DatatransForm.php +++ b/src/PluginForm/DatatransForm.php @@ -2,9 +2,7 @@ namespace Drupal\commerce_datatrans\PluginForm; -use Drupal\commerce_datatrans\DatatransHelper; use Drupal\commerce_payment\PluginForm\PaymentOffsiteForm; -use Drupal\commerce_price\Entity\Currency; use Drupal\Core\Form\FormStateInterface; /** @@ -20,53 +18,18 @@ class DatatransForm extends PaymentOffsiteForm { /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */ $payment = $this->entity; + /** @var \Drupal\commerce_datatrans\Plugin\Commerce\PaymentGateway\Datatrans $gateway */ $gateway = $payment->getPaymentGateway()->getPlugin(); - $gateway_config = $gateway->getConfiguration(); - $order = $payment->getOrder(); - $currency_code = $payment->getAmount()->getCurrencyCode(); - /** @var \Drupal\commerce_price\Entity\CurrencyInterface $currency */ - $currency = Currency::load($currency_code); + $response = $gateway->initializePayment($payment, [ + 'redirect' => [ + 'successUrl' => $form['#return_url'], + 'cancelUrl' => $form['#cancel_url'], + 'errorUrl' => $form['#return_url'], + ] + ]); - // Calculate the amount in the form Datatrans expects it. - $amount = intval($order->getTotalPrice()->getNumber() * pow(10, $currency->getFractionDigits())); - - $data = [ - 'merchantId' => $gateway_config['merchant_id'], - 'amount' => $amount, - 'refno' => $order->id(), - 'sign' => NULL, - 'currency' => $currency_code, - 'successUrl' => $form['#return_url'], - 'errorUrl' => $form['#return_url'], - 'cancelUrl' => $form['#cancel_url'], - 'security_level' => $gateway_config['security_level'], - ]; - - // Request type. - if (!empty($gateway_config['req_type']) && $gateway_config['req_type'] != 'ignore') { - $data['reqtype'] = $gateway_config['req_type']; - } - - // Handle security levels. - switch ($gateway_config['security_level']) { - case 1: - $data['sign'] = $gateway_config['sign']; - break; - - case 2: - // Generates the sign. - $data['sign'] = DatatransHelper::generateSign($gateway_config['hmac_key'], $gateway_config['merchant_id'], $amount, $currency_code, $order->id()); - break; - } - - // If use alias option was enabled in method configuration apply this for - // this payment method plugin. - if ($gateway_config['use_alias']) { - $data['useAlias'] = 'true'; - } - - return $this->buildRedirectForm($form, $form_state, $gateway_config['service_url'], $data, static::REDIRECT_POST); + return $this->buildRedirectForm($form, $form_state, $response->getLocation(), []); } } diff --git a/tests/src/Kernel/DatatransKernelTest.php b/tests/src/Kernel/DatatransGatewayTest.php similarity index 59% rename from tests/src/Kernel/DatatransKernelTest.php rename to tests/src/Kernel/DatatransGatewayTest.php index 833c648402e9e1081a757f0dee1c0fd524106079..483a03add7614d52038d9f11f462bccf0e955974 100644 --- a/tests/src/Kernel/DatatransKernelTest.php +++ b/tests/src/Kernel/DatatransGatewayTest.php @@ -2,12 +2,9 @@ namespace Drupal\Tests\commerce_datatrans\Kernel; -use Drupal\commerce_order\Entity\OrderItemType; use Drupal\commerce_payment\Entity\Payment; -use Drupal\commerce_payment\Entity\PaymentGateway; use Drupal\commerce_price\Price; -use Drupal\Tests\commerce\Kernel\CommerceKernelTestBase; -use Drupal\commerce_datatrans\Plugin\Commerce\PaymentGateway\Datatrans; +use Drupal\Component\Serialization\Json; use GuzzleHttp\ClientInterface; use GuzzleHttp\Psr7\Response; use Symfony\Component\HttpFoundation\Request; @@ -17,107 +14,86 @@ use Symfony\Component\HttpFoundation\Request; * * @group commerce_datatrans */ -class DatatransKernelTest extends CommerceKernelTestBase { +class DatatransGatewayTest extends DatatransKernelTestBase { /** - * The entity type manager. - * - * @var \Drupal\Core\Entity\EntityTypeManagerInterface + * @covers ::onReturn + * @covers ::processPayment */ - protected $entityTypeManager; + public function testOnReturnExistingNewPayment() { - /** - * The order. - * - * @var \Drupal\commerce_order\Entity\OrderInterface - */ - protected $order; + $http_client = $this->prophesize(ClientInterface::class); + $expected_options = [ + 'allow_redirects' => FALSE, + 'auth' => ['', 'secret'], + 'json' => [], + 'headers' => ['Content-Type' => 'application/json'], + ]; - /** - * The payment gateway config entity. - * - * @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface - */ - protected $paymentGateway; - - public static $modules = [ - 'entity_reference_revisions', - 'state_machine', - 'profile', - 'commerce_number_pattern', - 'commerce_order', - 'commerce_payment', - 'commerce_datatrans', - ]; + $transaction_data = $this->getTransactionData(); + $response = new Response(200, [], Json::encode($transaction_data)); - /** - * {@inheritdoc} - */ - protected function setUp() { - parent::setUp(); - - $this->installEntitySchema('commerce_order'); - $this->installEntitySchema('commerce_order_item'); - $this->installEntitySchema('commerce_payment'); - $this->installConfig(['commerce_order']); - - OrderItemType::create([ - 'id' => 'default', - 'label' => 'Default', - 'orderType' => 'default', - ])->save(); - - $this->entityTypeManager = $this->container->get('entity_type.manager'); - - // Create an order. - $this->order = $this->entityTypeManager->getStorage('commerce_order')->create([ - 'type' => 'default', - 'order_number' => '1', - 'store_id' => $this->store->id(), - 'state' => 'draft', - ]); - $this->order->save(); - - $order_item = $this->entityTypeManager->getStorage('commerce_order_item')->create([ - 'type' => 'default', - 'unit_price' => [ - 'number' => '999', - 'currency_code' => 'USD', - ], - ]); - $order_item->save(); - $this->order->setItems([$order_item]); - $this->order->save(); + $http_client->request('GET', 'https://api.sandbox.datatrans.com/v1/transactions/123', $expected_options)->willReturn($response); + $this->container->set('http_client', $http_client->reveal()); - $this->paymentGateway = PaymentGateway::create([ - 'id' => 'datatrans_id', - 'plugin' => 'datatrans', + $datatrans = $this->getDatatransGateway(); + + // Create an existing, pending payment. + $payment = Payment::create([ + 'payment_gateway' => $this->paymentGateway->id(), + 'remote_id' => 123, + 'order_id' => $this->order->id(), + 'state' => 'pending', + 'amount' => $this->order->getTotalPrice(), ]); - $this->paymentGateway->save(); + $payment->save(); + + // Create the request and call onReturn(). + $request = Request::create('', 'GET', ['datatransTrxId' => 123]); + $datatrans->onReturn($this->order, $request); + + // Process a second time, ensure that no duplicate is created. + $datatrans->onReturn($this->order, $request); + + $payments = $this->entityTypeManager->getStorage('commerce_payment')->loadMultiple(NULL); + $this->assertCount(1, $payments); + + /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */ + $payment = reset($payments); + + $this->assertEquals('completed', $payment->getState()->value); + $this->assertEquals(123, $payment->getRemoteId()); + $this->assertEquals($this->order->getTotalPrice(), $payment->getAmount()); + $this->assertEquals('datatrans_id', $payment->getPaymentGatewayId()); + $this->assertEquals(\Drupal::time()->getRequestTime(), $payment->getAuthorizedTime()); + $this->assertEquals(\Drupal::time()->getRequestTime(), $payment->getCompletedTime()); + } /** * @covers ::onReturn - * @covers ::validateResponseData * @covers ::processPayment */ public function testOnReturnComplete() { + + $http_client = $this->prophesize(ClientInterface::class); + $expected_options = [ + 'allow_redirects' => FALSE, + 'auth' => ['', 'secret'], + 'json' => [], + 'headers' => ['Content-Type' => 'application/json'], + ]; + + $transaction_data = $this->getTransactionData(); + $response = new Response(200, [], Json::encode($transaction_data)); + + $http_client->request('GET', 'https://api.sandbox.datatrans.com/v1/transactions/123', $expected_options)->willReturn($response); + $this->container->set('http_client', $http_client->reveal()); + $datatrans = $this->getDatatransGateway(); // Create the request and call onReturn(). - $request = Request::create('', 'POST'); - $request->request->add([ - 'refno' => $this->order->id(), - 'status' => 'success', - 'security_level' => 2, - 'uppTransactionId' => 123, - 'authorizationCode' => 456, - 'amount' => 999, - 'currency' => 'USD', - 'sign2' => 'f6ecaff66282456e3a949e0ef19daf4453bf70682bda2e981fd1193ea9da7833', - 'responseMessage' => 'test', - 'reqtype' => 'CAA', - ]); + $request = Request::create('', 'GET', ['datatransTrxId' => 123]); $datatrans->onReturn($this->order, $request); // Process a second time, ensure that no duplicate is created. @@ -133,31 +109,33 @@ class DatatransKernelTest extends CommerceKernelTestBase { $this->assertEquals(123, $payment->getRemoteId()); $this->assertEquals($this->order->getTotalPrice(), $payment->getAmount()); $this->assertEquals('datatrans_id', $payment->getPaymentGatewayId()); + $this->assertEquals(\Drupal::time()->getRequestTime(), $payment->getAuthorizedTime()); + $this->assertEquals(\Drupal::time()->getRequestTime(), $payment->getCompletedTime()); } /** * @covers ::onReturn - * @covers ::validateResponseData * @covers ::processPayment */ public function testOnReturnAuthorizeOnly() { + $http_client = $this->prophesize(ClientInterface::class); + $expected_options = [ + 'allow_redirects' => FALSE, + 'auth' => ['', 'secret'], + 'json' => [], + 'headers' => ['Content-Type' => 'application/json'], + ]; + + $response = new Response(200, [], Json::encode($this->getTransactionData('authorized'))); + + $http_client->request('GET', 'https://api.sandbox.datatrans.com/v1/transactions/123', $expected_options)->willReturn($response); + $this->container->set('http_client', $http_client->reveal()); + // Initialize the datatrans gateway. $datatrans = $this->getDatatransGateway(); // Create the request and call onReturn(). - $request = Request::create('', 'POST'); - $request->request->add([ - 'refno' => $this->order->id(), - 'status' => 'success', - 'security_level' => 2, - 'uppTransactionId' => 123, - 'authorizationCode' => 456, - 'amount' => 999, - 'currency' => 'USD', - 'sign2' => 'f6ecaff66282456e3a949e0ef19daf4453bf70682bda2e981fd1193ea9da7833', - 'responseMessage' => 'test', - 'reqtype' => 'NOA', - ]); + $request = Request::create('', 'GET', ['datatransTrxId' => 123]); $datatrans->onReturn($this->order, $request); $payments = $this->entityTypeManager->getStorage('commerce_payment')->loadMultiple(NULL); @@ -170,6 +148,8 @@ class DatatransKernelTest extends CommerceKernelTestBase { $this->assertEquals(123, $payment->getRemoteId()); $this->assertEquals($this->order->getTotalPrice(), $payment->getAmount()); $this->assertEquals('datatrans_id', $payment->getPaymentGatewayId()); + $this->assertEquals(\Drupal::time()->getRequestTime(), $payment->getAuthorizedTime()); + $this->assertNull($payment->getCompletedTime()); } /** @@ -253,19 +233,4 @@ class DatatransKernelTest extends CommerceKernelTestBase { $this->assertEquals('datatrans_id', $payment->getPaymentGatewayId()); } - /** - * Returns the datatrans gateway. - * - * @return \Drupal\commerce_datatrans\Plugin\Commerce\PaymentGateway\Datatrans - * The datatrans gateway. - */ - protected function getDatatransGateway() { - $configuration = [ - '_entity' => $this->paymentGateway, - 'password' => 'secret', - ]; - $gateway_plugin_manager = $this->container->get('plugin.manager.commerce_payment_gateway'); - return $gateway_plugin_manager->createInstance('datatrans', $configuration); - } - } diff --git a/tests/src/Kernel/DatatransKernelTestBase.php b/tests/src/Kernel/DatatransKernelTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..123c2e55e19addfd40013e2a309abb342d11c0fe --- /dev/null +++ b/tests/src/Kernel/DatatransKernelTestBase.php @@ -0,0 +1,155 @@ +<?php + +namespace Drupal\Tests\commerce_datatrans\Kernel; + +use Drupal\commerce_order\Entity\OrderItemType; +use Drupal\commerce_payment\Entity\PaymentGateway; +use Drupal\Tests\commerce\Kernel\CommerceKernelTestBase; + +/** + * Base class for Datatrans kernel tests. + */ +abstract class DatatransKernelTestBase extends CommerceKernelTestBase { + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * The order. + * + * @var \Drupal\commerce_order\Entity\OrderInterface + */ + protected $order; + + /** + * The payment gateway config entity. + * + * @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface + */ + protected $paymentGateway; + + public static $modules = [ + 'entity_reference_revisions', + 'state_machine', + 'profile', + 'commerce_number_pattern', + 'commerce_order', + 'commerce_payment', + 'commerce_datatrans', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->installEntitySchema('commerce_order'); + $this->installEntitySchema('commerce_order_item'); + $this->installEntitySchema('commerce_payment'); + $this->installConfig(['commerce_order']); + + OrderItemType::create([ + 'id' => 'default', + 'label' => 'Default', + 'orderType' => 'default', + ])->save(); + + $this->entityTypeManager = $this->container->get('entity_type.manager'); + + // Create an order. + $this->order = $this->entityTypeManager->getStorage('commerce_order')->create([ + 'type' => 'default', + 'order_number' => '1', + 'store_id' => $this->store->id(), + 'state' => 'draft', + ]); + $this->order->save(); + + $order_item = $this->entityTypeManager->getStorage('commerce_order_item')->create([ + 'type' => 'default', + 'unit_price' => [ + 'number' => '999', + 'currency_code' => 'USD', + ], + ]); + $order_item->save(); + $this->order->setItems([$order_item]); + $this->paymentGateway = PaymentGateway::create([ + 'id' => 'datatrans_id', + 'plugin' => 'datatrans', + 'configuration' => [ + 'password' => 'secret', + 'hmac_key_2' => '06c0d8316be07c3c7a701ab3e14c802f3925f0daa6855d38a4de19e58bccc0ef3868a50284586db0ca3822df7e89a2a02b528cd4d8e0a5221fecf9a94314b56d', + ] + ]); + $this->paymentGateway->save(); + + $this->order->set('payment_gateway', $this->paymentGateway->id()); + $this->order->save(); + } + + /** + * Returns the datatrans gateway. + * + * @return \Drupal\commerce_datatrans\Plugin\Commerce\PaymentGateway\Datatrans + * The datatrans gateway. + */ + protected function getDatatransGateway() { + $configuration = [ + '_entity' => $this->paymentGateway, + ] + $this->paymentGateway->getPluginConfiguration(); + $gateway_plugin_manager = $this->container->get('plugin.manager.commerce_payment_gateway'); + return $gateway_plugin_manager->createInstance('datatrans', $configuration); + } + + /** + * Returns example transaction data. + * + * @param string $status + * The payment status. + * + * @return array + * The data as returned by the Datatrans API. + */ + protected function getTransactionData(string $status = 'settled'): array { + return [ + 'transactionId' => '123', + 'type' => 'payment', + 'status' => $status, + 'currency' => 'USD', + 'refno' => '1', + 'paymentMethod' => 'VIS', + 'detail' => [ + 'authorize' => [ + 'amount' => 999, + 'acquirerAuthorizationCode' => '180001', + ], + 'settle' => [ + 'amount' => 999, + ], + ], + 'card' => [ + 'masked' => '400360xxxxxx0006', + 'expiryMonth' => '12', + 'expiryYear' => '21', + 'info' => [ + 'brand' => 'VISA', + 'type' => 'credit', + 'usage' => 'corporate', + 'country' => 'CH', + 'issuer' => 'DATATRANS', + ], + '3D' => [ + 'authenticationResponse' => 'D', + ], + ], + 'history' => [], + ]; + } + +} diff --git a/tests/src/Kernel/DatatransWebhookTest.php b/tests/src/Kernel/DatatransWebhookTest.php new file mode 100644 index 0000000000000000000000000000000000000000..d81a35e47214ba59b6aa111253ef02798142c3b2 --- /dev/null +++ b/tests/src/Kernel/DatatransWebhookTest.php @@ -0,0 +1,84 @@ +<?php + +namespace Drupal\Tests\commerce_datatrans\Kernel; + +use Drupal\commerce_datatrans\Controller\PaymentNotificationController; +use Drupal\Component\Serialization\Json; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +/** + * @coversDefaultClass \Drupal\commerce_datatrans\Controller\PaymentNotificationController + * + * @group commerce_datatrans + */ +class DatatransWebhookTest extends DatatransKernelTestBase { + + /** + * @covers ::notifyPage + */ + public function testWebhookSuccess() { + // Create the request and call the notify method. + $request = Request::create('', 'POST', [], [], [], [], Json::encode($this->getTransactionData())); + $request->headers->set('Datatrans-Signature', 't=12435676,s0=b25fc55b8472be2d603f22c3f8678f144a09a20c7c15be1753eefece9d58dbff'); + + $controller = PaymentNotificationController::create($this->container); + $controller->notifyPage($request); + + // Process a second time, ensure that no duplicate is created. + $controller->notifyPage($request); + + $payments = $this->entityTypeManager->getStorage('commerce_payment')->loadMultiple(NULL); + $this->assertCount(1, $payments); + + /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */ + $payment = reset($payments); + + $this->assertEquals('completed', $payment->getState()->value); + $this->assertEquals(123, $payment->getRemoteId()); + $this->assertEquals($this->order->getTotalPrice(), $payment->getAmount()); + $this->assertEquals('datatrans_id', $payment->getPaymentGatewayId()); + } + + /** + * @covers ::notifyPage + */ + public function testWebhookNoData() { + // Create the request and call the notify method. + $request = Request::create('', 'POST', [], [], [], []); + + $this->expectException(NotFoundHttpException::class); + + $controller = PaymentNotificationController::create($this->container); + $controller->notifyPage($request); + } + + /** + * @covers ::notifyPage + */ + public function testWebhookNoSignature() { + // Create the request and call the notify method. + $request = Request::create('', 'POST', [], [], [], [], Json::encode($this->getTransactionData())); + + $this->expectException(AccessDeniedHttpException::class); + + $controller = PaymentNotificationController::create($this->container); + $controller->notifyPage($request); + } + + /** + * @covers ::notifyPage + */ + public function testWebhookWrongSignature() { + // Create the request and call the notify method. + $request = Request::create('', 'POST', [], [], [], [], Json::encode($this->getTransactionData())); + $request->headers->set('Datatrans-Signature', 't=12435676,s0=wrong'); + + $this->expectException(AccessDeniedHttpException::class); + + $controller = PaymentNotificationController::create($this->container); + $controller->notifyPage($request); + } + +}