Commit 19a0f6c8 authored by Vadym Abramchuk's avatar Vadym Abramchuk
Browse files

Issue #3270758 by abramm: Implement 3D Secure flow for new and existing credit cards

parent fe486656
Loading
Loading
Loading
Loading
+0 −48
Original line number Diff line number Diff line
@@ -2,8 +2,6 @@

namespace Drupal\commerce_omise;

use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\commerce_payment\Entity\PaymentGatewayInterface;
use Drupal\commerce_payment\Exception\AuthenticationException;
use Drupal\commerce_payment\Exception\DeclineException;
use Drupal\commerce_payment\Exception\HardDeclineException;
@@ -100,50 +98,4 @@ class OmiseUtil implements OmiseUtilInterface {
    }
  }

  /**
   * {@inheritdoc}
   */
  public function getOrderPayment(OrderInterface $order) {
    $payment_ids = $this
      ->paymentStorage
      ->getQuery()
      ->condition('order_id', $order->id())
      ->condition('payment_gateway', $this->getOmisePaymentGatewaysIds(), 'IN')
      ->execute();

    if (empty($payment_ids)) {
      // Throw error early if the query returned empty result.
      throw new \InvalidArgumentException('No Omise payments found for order ID ' . $order->id());
    }

    $payments = $this
      ->paymentStorage
      ->loadMultiple($payment_ids);

    if (count($payments) > 1) {
      throw new \InvalidArgumentException('More than one Omise payment found for order ID ' . $order->id());
    }

    $payment = reset($payments);
    if (!$payment) {
      throw new \InvalidArgumentException('No Omise payments found for order ID ' . $order->id());
    }
    return $payment;
  }

  /**
   * Returns list of IDs of all enabled Omise payment gateways.
   */
  protected function getOmisePaymentGatewaysIds() {
    $paymentGateways = array_filter(
      $this->paymentGatewayStorage->loadMultiple(),
      function (PaymentGatewayInterface $gateway) {
        return $gateway->getPluginId() === 'omise' && $gateway->status();
      });

    return array_map(function (PaymentGatewayInterface $gateway) {
      return $gateway->id();
    }, $paymentGateways);
  }

}
+0 −15
Original line number Diff line number Diff line
@@ -2,7 +2,6 @@

namespace Drupal\commerce_omise;

use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\commerce_price\Price;

/**
@@ -43,18 +42,4 @@ interface OmiseUtilInterface {
   */
  public function handleException(\OmiseException $exception);

  /**
   * Retrieves Omise payment from the order.
   *
   * @param \Drupal\commerce_order\Entity\OrderInterface $order
   *   The order entity.
   *
   * @return \Drupal\commerce_payment\Entity\PaymentInterface
   *   The Omise payment entity corresponding given order.
   *
   * @throws \InvalidArgumentException
   *   Thrown if there are no or more than one Omise payments for the order.
   */
  public function getOrderPayment(OrderInterface $order);

}
+163 −17
Original line number Diff line number Diff line
@@ -2,7 +2,9 @@

namespace Drupal\commerce_omise\Plugin\Commerce\PaymentGateway;

use Drupal\commerce\Response\NeedsRedirectException;
use Drupal\commerce_omise\OmiseUtilInterface;
use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\commerce_payment\CreditCard;
use Drupal\commerce_payment\Entity\PaymentInterface;
use Drupal\commerce_payment\Entity\PaymentMethodInterface;
@@ -14,7 +16,9 @@ use Drupal\commerce_price\Price;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;

/**
 * Provides the Omise payment gateway.
@@ -111,6 +115,7 @@ class Omise extends OnsitePaymentGatewayBase implements OmiseInterface {
    return [
      'secret_key' => '',
      'public_key' => '',
      '3d_secure' => FALSE,
    ] + parent::defaultConfiguration();
  }

@@ -134,6 +139,13 @@ class Omise extends OnsitePaymentGatewayBase implements OmiseInterface {
      '#required' => TRUE,
    ];

    $form['3d_secure'] = [
      '#type' => 'checkbox',
      '#title' => $this->t('Enable 3D Secure support'),
      '#default_value' => !empty($this->configuration['3d_secure']),
      '#required' => FALSE,
    ];

    return $form;
  }

@@ -145,8 +157,9 @@ class Omise extends OnsitePaymentGatewayBase implements OmiseInterface {

    if (!$form_state->getErrors()) {
      $values = $form_state->getValue($form['#parents']);
      $this->configuration['secret_key'] = $values['secret_key'];
      $this->configuration['public_key'] = $values['public_key'];
      $this->configuration['secret_key'] = $values['secret_key'];
      $this->configuration['3d_secure'] = $values['3d_secure'];
    }
  }

@@ -157,39 +170,69 @@ class Omise extends OnsitePaymentGatewayBase implements OmiseInterface {
    $this->assertPaymentState($payment, ['new']);
    $payment_method = $payment->getPaymentMethod();
    $this->assertPaymentMethod($payment_method);
    $amount = $payment->getAmount();
    $currency_code = $payment->getAmount()->getCurrencyCode();

    if ($payment->isNew()) {
      // Save the payment entity to get its ID in case if it's not available
      // yet.
      $payment->save();
    }

    $transaction_data = [
      'currency' => $currency_code,
      'amount' => $this->util->formatNumber($amount),
      'currency' => $payment->getAmount()->getCurrencyCode(),
      'amount' => $this->util->formatNumber($payment->getAmount()),
      'capture' => $capture,
      // Always passing in a card since the user have selected or entered the
      // specific card in Drupal user interface.
      // Passing the 'customer' param without a card would always force using
      // users default card stored at Omise regardless of what card has been
      // selected in Drupal.
      'card' => $payment_method->getRemoteId(),
    ];

    $owner = $payment_method->getOwner();
    if ($owner && $owner->isAuthenticated()) {
      // Add remote customer ID if it's available; this is required for
      // stored cards.
      // @see doCreatePaymentMethod()
      $transaction_data['customer'] = $this->getRemoteCustomerId($owner);
    }
    else {
      $transaction_data['card'] = $payment_method->getRemoteId();

    if (!empty($this->configuration['3d_secure'])) {
      $transaction_data['return_uri'] = Url::fromRoute('commerce_payment.checkout.return', [
        'commerce_order' => $payment->getOrderId(),
        'step' => 'payment',
      ], [
        'absolute' => TRUE,
        'query' => [
          'payment_id' => $payment->id(),
        ],
      ])->toString();
    }

    try {
      $result = \OmiseCharge::create($transaction_data, $this->configuration['public_key'], $this->configuration['secret_key']);
      if (!empty($result['status'] && $result['status'] === 'failed')) {
        throw \OmiseException::getInstance([
          'code' => $result['failure_code'],
          'message' => $result['failure_message'],
        ]);
      }
      $result = \OmiseCharge::create(
        $transaction_data,
        $this->configuration['public_key'],
        $this->configuration['secret_key']
      );
      $this->validateChargeStatus($result);
    }
    catch (\OmiseException $e) {
      $this->util->handleException($e);
    }

    $payment->setRemoteId($result['id']);
    if (!empty($result['authorize_uri'])) {
      // Payment gateway requested redirecting user to their payment
      // authorization page (e.g. for 3D Secure check).
      // Store the payment (so the payment remote ID gets stored) and redirect
      // user.
      $payment->save();
      throw new NeedsRedirectException($result['authorize_uri']);
    }

    $next_state = $capture ? 'completed' : 'authorization';
    $payment->setState($next_state);
    $payment->setRemoteId($result['id']);
    $payment->save();
  }

@@ -337,6 +380,56 @@ class Omise extends OnsitePaymentGatewayBase implements OmiseInterface {
    $payment_method->delete();
  }

  /**
   * {@inheritdoc}
   */
  public function updateRemotePaymentStatus(PaymentInterface $payment) {
    $this->assertPaymentState($payment, ['new']);

    try {
      $charge = \OmiseCharge::retrieve(
        $payment->getRemoteId(),
        $this->configuration['public_key'],
        $this->configuration['secret_key']
      );
      $this->validateChargeStatus($charge);
    }
    catch (\OmiseException $e) {
      // Remove payment method from the order.
      // Note we are NOT removing the payment method; it's still valid (since
      // it passed the initial card number check), it's just not usable for this
      // order (most likely due to invalid 3D Secure response provided by user).
      // Removing the order payment method would force user re-entering (or
      // selecting) it again.
      $order = $payment->getOrder();
      $order->get('payment_method')->setValue(NULL);
      $order->save();

      $this->util->handleException($e);
    }

    $payment->setState('completed');
    $payment->save();
  }

  /**
   * Validates Omise charge status.
   *
   * @param \OmiseCharge $charge
   *   The Omise charge object.
   *
   * @throws \OmiseException
   *   Omise errors are thrown if charge failed.
   */
  protected function validateChargeStatus(\OmiseCharge $charge) {
    if (!empty($charge['status'] && $charge['status'] === 'failed')) {
      throw \OmiseException::getInstance([
        'code' => $charge['failure_code'],
        'message' => $charge['failure_message'],
      ]);
    }
  }

  /**
   * Creates the payment method on the gateway.
   *
@@ -406,9 +499,12 @@ class Omise extends OnsitePaymentGatewayBase implements OmiseInterface {

        $this->setRemoteCustomerId($owner, $customer['id']);
        $owner->save();
        foreach ($cards['data'] as $card) {
          return $card;

        if (count($cards['data']) !== 1) {
          throw new \RuntimeException(sprintf('Expected customer to have only 1 card, %d found.', count($cards['data'])));
        }

        return reset($cards['data']);
      }
      catch (\OmiseException $e) {
        $this->util->handleException($e);
@@ -428,4 +524,54 @@ class Omise extends OnsitePaymentGatewayBase implements OmiseInterface {
    return [];
  }

  /**
   * {@inheritdoc}
   */
  public function getNotifyUrl() {
    // This is a copy of OffsitePaymentGatewayBase::getNotifyUrl().
    // Omise doesn't support usual Notify (i.e. when notify URL is passed in as
    // part of the API call), however, there's a similar thing called Webhooks
    // so keeping this code here for future reference.
    return Url::fromRoute('commerce_payment.notify', [
      'commerce_payment_gateway' => $this->parentEntity->id(),
    ], ['absolute' => TRUE]);
  }

  /**
   * {@inheritdoc}
   */
  public function onReturn(OrderInterface $order, Request $request) {
    $payment_id = $request->query->get('payment_id');
    if ($payment_id === NULL || !is_numeric($payment_id)) {
      throw new \InvalidArgumentException('Missing/invalid payment ID query parameter.');
    }

    $payment = $this
      ->entityTypeManager
      ->getStorage('commerce_payment')
      ->load($payment_id);
    if (!$payment) {
      throw new \RuntimeException('The payment with given ID does not exist.');
    }

    $this->updateRemotePaymentStatus($payment);
  }

  /**
   * {@inheritdoc}
   */
  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(),
    ]));
  }

  /**
   * {@inheritdoc}
   */
  public function onNotify(Request $request) {
    // This may change later if we implement Webhooks support.
    throw new \LogicException('Omise payment gateway does not support Notify method');
  }

}
+18 −1
Original line number Diff line number Diff line
@@ -2,6 +2,8 @@

namespace Drupal\commerce_omise\Plugin\Commerce\PaymentGateway;

use Drupal\commerce_payment\Entity\PaymentInterface;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OffsitePaymentGatewayInterface;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OnsitePaymentGatewayInterface;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsAuthorizationsInterface;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsRefundsInterface;
@@ -9,7 +11,11 @@ use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsRefundsInterf
/**
 * Provides the interface for the Omise payment gateway.
 */
interface OmiseInterface extends OnsitePaymentGatewayInterface, SupportsAuthorizationsInterface, SupportsRefundsInterface {
interface OmiseInterface extends
    OnsitePaymentGatewayInterface,
    SupportsAuthorizationsInterface,
    SupportsRefundsInterface,
    OffsitePaymentGatewayInterface {

  /**
   * Get the Omise API Public key set for the payment gateway.
@@ -19,4 +25,15 @@ interface OmiseInterface extends OnsitePaymentGatewayInterface, SupportsAuthoriz
   */
  public function getPublicKey();

  /**
   * Retrieve and update payment status for an existing charge.
   *
   * @param \Drupal\commerce_payment\Entity\PaymentInterface $payment
   *   The payment.
   *
   * @throws \Drupal\commerce_payment\Exception\PaymentGatewayException
   *   The Commerce exception.
   */
  public function updateRemotePaymentStatus(PaymentInterface $payment);

}