Loading src/Plugin/Commerce/PaymentGateway/Paytrail.php +52 −10 Original line number Diff line number Diff line Loading @@ -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 { Loading @@ -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(); } Loading @@ -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. Loading @@ -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, Loading @@ -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()); } Loading Loading @@ -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']); Loading @@ -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, Loading src/Plugin/QueueWorker/NotificationWorker.php +11 −5 Original line number Diff line number Diff line Loading @@ -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); Loading @@ -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. Loading @@ -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) Loading src/RequestBuilder/PaymentRequestBuilder.php +9 −15 Original line number Diff line number Diff line Loading @@ -31,9 +31,6 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface; */ class PaymentRequestBuilder extends RequestBuilderBase { use TransactionIdTrait; use StampKeyTrait; /** * Constructs a new instance. * Loading Loading @@ -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. * Loading @@ -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); Loading Loading @@ -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); } /** Loading Loading @@ -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; } Loading src/RequestBuilder/RefundRequestBuilder.php +8 −8 Original line number Diff line number Diff line Loading @@ -25,8 +25,6 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface; */ class RefundRequestBuilder extends RequestBuilderBase { use TransactionIdTrait; /** * Constructs a new instance. * Loading Loading @@ -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 Loading @@ -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); Loading Loading @@ -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); Loading src/RequestBuilder/StampKeyTrait.phpdeleted 100644 → 0 +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
src/Plugin/Commerce/PaymentGateway/Paytrail.php +52 −10 Original line number Diff line number Diff line Loading @@ -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 { Loading @@ -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(); } Loading @@ -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. Loading @@ -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, Loading @@ -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()); } Loading Loading @@ -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']); Loading @@ -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, Loading
src/Plugin/QueueWorker/NotificationWorker.php +11 −5 Original line number Diff line number Diff line Loading @@ -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); Loading @@ -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. Loading @@ -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) Loading
src/RequestBuilder/PaymentRequestBuilder.php +9 −15 Original line number Diff line number Diff line Loading @@ -31,9 +31,6 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface; */ class PaymentRequestBuilder extends RequestBuilderBase { use TransactionIdTrait; use StampKeyTrait; /** * Constructs a new instance. * Loading Loading @@ -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. * Loading @@ -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); Loading Loading @@ -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); } /** Loading Loading @@ -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; } Loading
src/RequestBuilder/RefundRequestBuilder.php +8 −8 Original line number Diff line number Diff line Loading @@ -25,8 +25,6 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface; */ class RefundRequestBuilder extends RequestBuilderBase { use TransactionIdTrait; /** * Constructs a new instance. * Loading Loading @@ -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 Loading @@ -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); Loading Loading @@ -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); Loading
src/RequestBuilder/StampKeyTrait.phpdeleted 100644 → 0 +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; } }