Verified Commit ddcd1edd authored by tuutti's avatar tuutti
Browse files

Issue #3274707 by tuutti: Payment using Mobile Pay fails

Issue #3314321: Add refund support to notification callback
Issue #3312490: Support Guzzle 7
parent f9153717
Loading
Loading
Loading
Loading
+52 −10
Original line number Diff line number Diff line
@@ -120,6 +120,15 @@ final class Paytrail extends PaytrailBase implements SupportsNotificationsInterf
   *   The response.
   */
  public function onNotify(Request $request) : Response {
    $callback = match ($request->get('callback-type')) {
      // Refunds can be asynchronous, meaning the refund can be in 'pending'
      // state. We always mark payments as refunded regardless of its state.
      // Return a 200 response to make sure Paytrail doesn't keep
      // calling this for no reason.
      'refund' => function (Request $request) : Response {
        return new Response();
      },
      default => function (Request $request) : Response {
        $storage = $this->entityTypeManager->getStorage('commerce_order');

        try {
@@ -129,8 +138,12 @@ final class Paytrail extends PaytrailBase implements SupportsNotificationsInterf
          }
          $this->validateResponse($order, $request);

          // Queue the order to avoid a race-condition between onNotify() and
          // onReturn() callbacks.
          // @see https://www.drupal.org/node/3268851
          $this->queue->createItem([
            'order_id' => $order->id(),
            'transaction_id' => $request->query->get('checkout-transaction-id'),
          ]);
          return new Response();
        }
@@ -139,6 +152,24 @@ final class Paytrail extends PaytrailBase implements SupportsNotificationsInterf
        }
        return new Response(status: Response::HTTP_FORBIDDEN);
      }
    };

    return $callback($request);
  }

  /**
   * {@inheritdoc}
   */
  public function getNotifyUrl(string $type = NULL) : Url {
    $url = parent::getNotifyUrl();

    if ($type) {
      $query = $url->getOption('query');
      $query['callback-type'] = $type;
      $url->setOption('query', $query);
    }
    return $url;
  }

  /**
   * Validate and store transaction for order.
@@ -152,7 +183,10 @@ final class Paytrail extends PaytrailBase implements SupportsNotificationsInterf
    try {
      $this->validateResponse($order, $request);

      $paymentResponse = $this->paymentRequest->get($order);
      $paymentResponse = $this->paymentRequest->get(
        $request->query->get('checkout-transaction-id'),
        $order
      );
      $this->assertResponseStatus($paymentResponse->getStatus(), [
        Payment::STATUS_OK,
        Payment::STATUS_PENDING,
@@ -175,12 +209,23 @@ final class Paytrail extends PaytrailBase implements SupportsNotificationsInterf
   *
   * @throws \Drupal\commerce_paytrail\Exception\SecurityHashMismatchException
   */
  protected function validateResponse(OrderInterface $order, Request $request) : void {
  private function validateResponse(OrderInterface $order, Request $request) : void {
    [
      'checkout-reference' => $requestOrderId,
      'checkout-transaction-id' => $transactionId,
    ] = $request->query->all();

    if (!$transactionId) {
      throw new SecurityHashMismatchException('Transaction ID not set.');
    }
    // onReturn() uses {commerce_order} to load the order, which is not a part
    // of the signature hash calculation. Make sure the order entity ID
    // matches the order id in 'checkout-reference' to prevent a valid return
    // URL from being reused.
    if (!$requestOrderId || $requestOrderId !== $order->id()) {
      throw new SecurityHashMismatchException('Order ID mismatch.');
    }
    $this->paymentRequest
      // onNotify() uses {commerce_order} to load the order which is not a part
      // of signature hash calculation. Make sure stamp matches with the stamp
      // saved in order entity so a valid return URL cannot be re-used.
      ->validateStamp($order, $request->query->get('checkout-stamp'))
      ->validateSignature($this, $request->query->all());
  }

@@ -223,9 +268,6 @@ final class Paytrail extends PaytrailBase implements SupportsNotificationsInterf

  /**
   * {@inheritdoc}
   *
   * @todo Refunds can be asynchronous in the future.
   * @see https://docs.paytrail.com/#/?id=refund
   */
  public function refundPayment(PaymentInterface $payment, Price $amount = NULL) : void {
    $this->assertPaymentState($payment, ['completed', 'partially_refunded']);
@@ -239,7 +281,7 @@ final class Paytrail extends PaytrailBase implements SupportsNotificationsInterf
    $newRefundedAmount = $oldRefundedAmount->add($amount);

    try {
      $response = $this->refundRequest->refund($order, $amount);
      $response = $this->refundRequest->refund($payment->getRemoteId(), $order, $amount);

      $this->assertResponseStatus($response->getStatus(), [
        RefundResponse::STATUS_OK,
+11 −5
Original line number Diff line number Diff line
@@ -80,12 +80,19 @@ final class NotificationWorker extends QueueWorkerBase implements ContainerFacto
   * {@inheritdoc}
   */
  public function processItem($data) : void {
    ['order_id' => $id] = $data;
    ['order_id' => $id, 'transaction_id' => $transactionId] = $data;

    // Order not found or is paid already, we can safely ignore the item.
    // Order not found or is paid already. We can safely ignore the item.
    if ((!$order = $this->orderStorage->load($id)) || $order->isPaid()) {
      return;
    }

    // The order validation/loading logic changed in 3.0-alpha4 release. Support
    // orders made before 3.0-alpha4 release.
    // @todo Remove this in 4.x.
    if (!$transactionId) {
      $transactionId = $order->getData('commerce_paytrail_transaction_id', NULL);
    }
    /** @var \Drupal\commerce_order\Entity\OrderInterface $order */
    $numTries = $order->getData(self::MAX_TRIES_SETTING, 0);

@@ -101,7 +108,7 @@ final class NotificationWorker extends QueueWorkerBase implements ContainerFacto
    }

    try {
      $paymentResponse = $this->paymentRequest->get($order);
      $paymentResponse = $this->paymentRequest->get($transactionId, $order);
    }
    catch (PaytrailPluginException) {
      // Nothing to do if dealing with non-paytrail order.
@@ -109,8 +116,7 @@ final class NotificationWorker extends QueueWorkerBase implements ContainerFacto
    }

    try {
      // Re-queue if order is not marked as paid. This probably should never
      // happen.
      // Re-queue if order is not marked as paid. This should never happen.
      if ($paymentResponse->getStatus() !== Payment::STATUS_OK) {
        throw new \InvalidArgumentException(
          sprintf('Order payment is not completed for order: %s', $id)
+9 −15
Original line number Diff line number Diff line
@@ -31,9 +31,6 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
 */
class PaymentRequestBuilder extends RequestBuilderBase {

  use TransactionIdTrait;
  use StampKeyTrait;

  /**
   * Constructs a new instance.
   *
@@ -95,6 +92,8 @@ class PaymentRequestBuilder extends RequestBuilderBase {
  /**
   * Gets the payment for given order.
   *
   * @param string $transactionId
   *   The transaction ID.
   * @param \Drupal\commerce_order\Entity\OrderInterface $order
   *   The order.
   *
@@ -105,8 +104,7 @@ class PaymentRequestBuilder extends RequestBuilderBase {
   * @throws \Drupal\commerce_paytrail\Exception\SecurityHashMismatchException
   * @throws \Paytrail\Payment\ApiException
   */
  public function get(OrderInterface $order) : Payment {
    $transactionId = $this->getTransactionId($order);
  public function get(string $transactionId, OrderInterface $order) : Payment {
    $configuration = $this->getPaymentPlugin($order)->getClientConfiguration();
    $headers = $this->createHeaders('GET', $configuration, $transactionId);

@@ -160,16 +158,7 @@ class PaymentRequestBuilder extends RequestBuilderBase {
          \GuzzleHttp\json_encode(ObjectSerializer::sanitizeForSerialization($request))
        ),
      );
    /** @var \Paytrail\Payment\Model\PaymentRequestResponse $response */
    $response = $this->getResponse($order, $response);

    // Save stamp and transaction id for later validation.
    $this
      ->setTransactionId($order, $response->getTransactionId())
      ->setStamp($order, $request->getStamp());
    $order->save();

    return $response;
    return $this->getResponse($order, $response);
  }

  /**
@@ -234,6 +223,11 @@ class PaymentRequestBuilder extends RequestBuilderBase {
    $this->eventDispatcher
      ->dispatch(new ModelEvent($request));

    // We use reference field to load the order. Make sure it cannot be changed.
    if ($request->getReference() !== $order->id()) {
      throw new \LogicException('The value of "reference" field cannot be changed.');
    }

    return $request;
  }

+8 −8
Original line number Diff line number Diff line
@@ -25,8 +25,6 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
 */
class RefundRequestBuilder extends RequestBuilderBase {

  use TransactionIdTrait;

  /**
   * Constructs a new instance.
   *
@@ -54,6 +52,8 @@ class RefundRequestBuilder extends RequestBuilderBase {
  /**
   * Refunds the given order and amount.
   *
   * @param string $transactionId
   *   The transaction ID.
   * @param \Drupal\commerce_order\Entity\OrderInterface $order
   *   The order to refund.
   * @param \Drupal\commerce_price\Price $amount
@@ -66,10 +66,10 @@ class RefundRequestBuilder extends RequestBuilderBase {
   * @throws \Drupal\commerce_paytrail\Exception\SecurityHashMismatchException
   * @throws \Paytrail\Payment\ApiException
   */
  public function refund(OrderInterface $order, Price $amount) : RefundResponse {
    $transactionId = $this->getTransactionId($order);
    $plugin = $this->getPaymentPlugin($order);
    $configuration = $plugin->getClientConfiguration();
  public function refund(string $transactionId, OrderInterface $order, Price $amount) : RefundResponse {
    $configuration = $this
      ->getPaymentPlugin($order)
      ->getClientConfiguration();
    $headers = $this->createHeaders('POST', $configuration, $transactionId);

    $request = $this->createRefundRequest($order, $amount, $headers->nonce);
@@ -119,8 +119,8 @@ class RefundRequestBuilder extends RequestBuilderBase {
      ->setRefundReference($order->id())
      ->setAmount($this->converter->toMinorUnits($amount))
      ->setCallbackUrls(new Callbacks([
        'success' => $plugin->getNotifyUrl()->toString(),
        'cancel' => $plugin->getNotifyUrl()->toString(),
        'success' => $plugin->getNotifyUrl('refund')->toString(),
        'cancel' => $plugin->getNotifyUrl('refund')->toString(),
      ]))
      ->setRefundStamp($nonce);

+0 −51
Original line number Diff line number Diff line
<?php

declare(strict_types = 1);

namespace Drupal\commerce_paytrail\RequestBuilder;

use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\commerce_paytrail\Exception\SecurityHashMismatchException;

/**
 * A trait to provide a way to read/write stamp keys.
 */
trait StampKeyTrait {

  /**
   * Stores the stamp in given order.
   *
   * @param \Drupal\commerce_order\Entity\OrderInterface $order
   *   The order.
   * @param string $stamp
   *   The stamp.
   *
   * @return $this
   *   The self.
   */
  protected function setStamp(OrderInterface $order, string $stamp) : self {
    $order->setData('commerce_paytrail_stamp', $stamp);
    return $this;
  }

  /**
   * Checks if returned stamp matches with stored one.
   *
   * @param \Drupal\commerce_order\Entity\OrderInterface $order
   *   The order to check.
   * @param string $expectedStamp
   *   The expected stamp.
   *
   * @return $this
   *   The self.
   *
   * @throws \Drupal\commerce_paytrail\Exception\SecurityHashMismatchException
   */
  public function validateStamp(OrderInterface $order, string $expectedStamp) : self {
    if ((!$stamp = $order->getData('commerce_paytrail_stamp')) || $stamp !== $expectedStamp) {
      throw new SecurityHashMismatchException('Stamp validation failed.');
    }
    return $this;
  }

}
Loading