Commit f8749bc8 authored by Dimitris Bozelos's avatar Dimitris Bozelos
Browse files

Issue #3263991 Transaction and payment updating

parent 290c46be
Loading
Loading
Loading
Loading
+7 −0
Original line number Diff line number Diff line
@@ -4,6 +4,13 @@ services:
    arguments:
      - '@entity_type.manager'

  commerce_sage_payments.subscriber.payment_updater:
    class: Drupal\commerce_sage_payments\EventSubscriber\PaymentUpdater
    arguments:
      - '@entity_type.manager'
    tags:
      - { name: event_subscriber }

  logger.channel.commerce_sage_payments:
    class: Drupal\Core\Logger\LoggerChannel
    factory: logger.factory:get
+178 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\commerce_sage_payments\EventSubscriber;

use Drupal\commerce_transaction\Entity\TransactionInterface;
use Drupal\state_machine\Event\WorkflowTransitionEvent;

use Drupal\Core\Entity\EntityTypeManagerInterface;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Updates the parent payment when a transaction is updated.
 */
class PaymentUpdater implements EventSubscriberInterface {

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * Constructs a new PaymentUpdater object.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager.
   */
  public function __construct(EntityTypeManagerInterface $entity_type_manager) {
    $this->entityTypeManager = $entity_type_manager;
  }

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents() {
    $events = [
      'commerce_transaction.settle_capture.post_transition' => ['updateForSettled', 0],
      'commerce_transaction.settle_sale.post_transition' => ['updateForSettled', 0],
      'commerce_transaction.settle_credit.post_transition' => ['updateForSettled', 0],
      'commerce_transaction.fail_capture.post_transition' => ['updateForFailed', 0],
      'commerce_transaction.fail_sale.post_transition' => ['updateForFailed', 0],
      'commerce_transaction.fail_credit.post_transition' => ['updateForFailed', 0],
      'commerce_transaction.decline_capture.post_transition' => ['updateForFailed', 0],
      'commerce_transaction.decline_sale.post_transition' => ['updateForFailed', 0],
      'commerce_transaction.decline_credit.post_transition' => ['updateForFailed', 0],
      'commerce_transaction.expire_capture.post_transition' => ['updateForFailed', 0],
      'commerce_transaction.expire_sale_post_transition' => ['updateForFailed', 0],
      'commerce_transaction.expire_credit.post_transition' => ['updateForFailed', 0],
    ];
    return $events;
  }

  /**
   * Updates the parent payment when a transaction is settled.
   *
   * @param \Drupal\state_machine\Event\WorkflowTransitionEvent $event
   *   The transition event.
   */
  public function updateForSettled(WorkflowTransitionEvent $event) {
    $this->updatePayment(
      $event->getEntity(),
      [
        'charge_state' => 'completed',
        'refund_callback' => [$this, 'updateRefundedPayment'],
      ]
    );
  }

  /**
   * Updates the parent payment when a transaction failed to settle.
   *
   * Remote states for failure are Error, Declined and Expired. We do not need
   * separate states for all cases at the payment level; we track this at the
   * transaction level. We simply mark the payment as `capture_failed` or
   * `refund_failed`.
   *
   * Store managers can see that a payment or a refund has failed and they can
   * either view the transaction details in Drupal (UI still to be implemented)
   * or in the Sage Payments Virtual Terminal.
   *
   * @param \Drupal\state_machine\Event\WorkflowTransitionEvent $event
   *   The transition event.
   */
  public function updateForFailed(WorkflowTransitionEvent $event) {
    $this->updatePayment(
      $event->getEntity(),
      [
        'charge_state' => 'capture_failed',
        'refund_state' => 'refund_failed',
      ]
    );
  }

  /**
   * Updates the payment for the given transaction.
   *
   * @param \Drupal\commerce_transaction\Entity\TransactionInterface $transaction
   *   The transaction.
   * @param array $options
   *   An associative array of options. For each transaction type (charge or
   *   refund) a state or a callable must be given. Supported options are:
   *   - charge_state (string, optional): The ID of the new state to set the
   *     payment to when the transaction is a charge.
   *   - charge_callback (callable, optional): The callable to call for
   *     executing custom logic for determining and setting the new payment
   *     state when the transaction is a charge.
   *   - refund_state (string, optional): The ID of the new state to set the
   *     payment to when the transaction is a refund.
   *   - refund_callback (callable, optional): The callable to call for
   *     executing custom logic for determining and setting the new payment
   *     state when the transaction is a refund.
   */
  protected function updatePayment(
    TransactionInterface $transaction,
    array $options
  ) {
    $payment_state = NULL;
    $type = $this->entityTypeManager
      ->getStorage('commerce_transaction_type')
      ->load($transaction->bundle())
      ->getType();

    if (empty($options["{$type}_callback"]) && empty($options["{$type}_state"])) {
      throw new \RuntimeException(sprintf(
        'No state or callback given when updating the payment for transaction with ID "%s" of type "%s".',
        $transaction->id(),
        $type
      ));
    }

    // If we are given a callable we use that; otherwise, we must have been
    // given the new payment state.
    if ($options["{$type}_callback"]) {
      call_user_func($options["{$type}_callback"], $transaction);
      return;
    }
    $payment_state = $options["{$type}_state"];

    // Update the payment entity.
    $payment = $transaction->getPayment();
    $payment->setState($payment_state);
    $this->entityTypeManager
      ->getStorage('commerce_payment')
      ->save($payment);
  }

  /**
   * Updates the payment for settled refunds.
   *
   * @param \Drupal\commerce_transaction\Entity\TransactionInterface $transaction
   *   The transaction.
   */
  protected function updateRefundedPayment(TransactionInterface $transaction) {
    $payment = $transaction->getPayment();
    $old_refunded_amount = $payment->getRefundedAmount();
    $new_refunded_amount = $old_refunded_amount->add($transaction->getAmount());
    // With the current implementation, we block issuing a refund from the UI if
    // there is already a refund pending. Therefore, there shouldn't exist other
    // refunds to account for when calculating the new state for the payment. It
    // should only be determined based on the state and the amount of the
    // current transaction. See
    // `\Drupal\commerce_sage_payments\Plugin\Commerce\PaymentGateway\Sevd::canRefundPayment()`.
    if ($new_refunded_amount->lessThan($payment->getAmount())) {
      $payment->setState('partially_refunded');
    }
    else {
      $payment->setState('refunded');
    }
    $payment->setRefundedAmount($new_refunded_amount);

    $this->entityTypeManager
      ->getStorage('commerce_payment')
      ->save($payment);
  }

}
+264 −1
Original line number Diff line number Diff line
@@ -3,6 +3,23 @@
namespace Drupal\commerce_sage_payments\Plugin\Commerce\PaymentType;

use Drupal\commerce_payment\Plugin\Commerce\PaymentType\PaymentTypeBase;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\PaymentGatewayInterface;
use Drupal\commerce_transaction\Entity\TransactionInterface;
use Drupal\commerce_transaction\Entity\TransactionTypeInterface;
use Drupal\commerce_transaction\MachineName\Field\Transaction as TransactionField;
use Drupal\commerce_transaction\Updater\SupportsTransactionUpdatingInterface;
use Drupal\state_machine\Plugin\Field\FieldType\StateItemInterface;
use Drupal\state_machine\Plugin\Workflow\WorkflowTransition;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;

use KrystalCode\SagePayments\Sdk\DirectApi\ClientBase;
use KrystalCode\SagePayments\Sdk\DirectApi\ClientFactory;
use KrystalCode\SagePayments\Sdk\DirectApi\ClientInterface;
use KrystalCode\SagePayments\Sdk\DirectApi\Resource\Charges as ChargesApi;
use KrystalCode\SagePayments\Sdk\DirectApi\Resource\Credits as CreditsApi;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Provides the payment type for Sage Payments.
@@ -13,7 +30,68 @@ use Drupal\commerce_payment\Plugin\Commerce\PaymentType\PaymentTypeBase;
 *   workflow = "payment_sage_payments",
 * )
 */
class SagePayments extends PaymentTypeBase {
class SagePayments extends PaymentTypeBase implements
  ContainerFactoryPluginInterface,
  SupportsTransactionUpdatingInterface {

  /**
   * The Sage Payments Direct API client factory.
   *
   * @var \KrystalCode\SagePayments\Sdk\DirectApi\ClientFactory
   */
  protected $clientFactory;

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * Constructs a new SagePayments 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 \KrystalCode\SagePayments\Sdk\DirectApi\ClientFactory $client_factory
   *   The Sage Payments Direct API client factory.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager.
   */
  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    ClientFactory $client_factory,
    EntityTypeManagerInterface $entity_type_manager
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);

    $this->clientFactory = $client_factory;
    $this->entityTypeManager = $entity_type_manager;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(
    ContainerInterface $container,
    array $configuration,
    $plugin_id,
    $plugin_definition
  ) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('commerce_sage_payments.api.factory'),
      $container->get('entity_type.manager')
    );
  }

  /**
   * {@inheritdoc}
@@ -22,4 +100,189 @@ class SagePayments extends PaymentTypeBase {
    return [];
  }

  /**
   * {@inheritdoc}
   */
  public function transactionUpdaterPendingStates(): array {
    return [
      'payment' => [
        'capture_pending',
        'refund_pending',
      ],
      'transaction' => [
        'capture_batch',
        'sale_batch',
        'credit_batch',
      ],
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function updateTransaction(TransactionInterface $transaction) {
    $response = $this->fetchUpdate($transaction);

    // If the request fails the client will throw an exception. The transaction
    // will remain in the `Batch` state and it will be updated next time the
    // transaction updater runs.
    //
    // Nothing to do if the transaction is still waiting to be processed.
    if ($response->status === 'Batch') {
      return;
    }

    // Simply set the transaction to the updated state. We will be updating the
    // payment in an event subscriber.
    $field_item = $transaction->get(TransactionField::REMOTE_STATE)->first();
    $field_item->applyTransition(
      $this->getTransactionTransition(
        $field_item,
        $response->type,
        $response->status
      )
    );
    $this->entityTypeManager
      ->getStorage('commerce_transaction')
      ->save($transaction);
  }

  /**
   * Fetches the update for the given transaction.
   *
   * @param \Drupal\commerce_transaction\Entity\TransactionInterface $transaction
   *   The transaction.
   *
   * @return object|null
   *   The response as an \stdClass object, or null if the response could
   *   not be decoded.
   *
   * @throws \RuntimeException
   *   When the transaction is of an unsupported type.
   */
  protected function fetchUpdate(TransactionInterface $transaction) {
    $type = $this->entityTypeManager
      ->getStorage('commerce_transaction_type')
      ->load($transaction->bundle())
      ->getType();

    switch ($type) {
      case TransactionTypeInterface::TYPE_CHARGE:
        return $this
          ->getApiClient(ChargesApi::ID, $transaction)
          ->getChargesDetail($transaction->getRemoteId());

      case TransactionTypeInterface::TYPE_REFUND:
        return $this
          ->getApiClient(CreditsApi::ID, $transaction)
          ->getCreditsDetail($transaction->getRemoteId());

      default:
        throw new \RuntimeException(sprintf(
          'Unsupported transaction type "%s", must be one of "%s" and "%s".',
          $type,
          TransactionTypeInterface::TYPE_CHARGE,
          TransactionTypeInterface::TYPE_REFUND
        ));
    }
  }

  /**
   * Returns the payment gateway plugin for the transaction's parent payment.
   *
   * @param \Drupal\commerce_transaction\Entity\TransactionInterface $transaction
   *   The transaction.
   *
   * @return \Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\PaymentGatewayInterface
   *   The payment gateway plugin.
   */
  protected function getTransactionGatewayPlugin(
    TransactionInterface $transaction
  ): PaymentGatewayInterface {
    return $transaction
      ->getPayment()
      ->getPaymentMethod()
      ->getPaymentGateway()
      ->getPlugin();
  }

  /**
   * Returns the transition for the given remote state field.
   *
   * Transitions are well defined from 'Batch' to success or error states. There
   * is therefore only one transition for the given target state and we can
   * determine it programmatically to reduce code complexity i.e. avoid control
   * statements.
   *
   * @param \Drupal\state_machine\Plugin\Field\FieldType\StateItemInterface $field_item
   *   The state field item.
   * @param string $remote_type
   *   The remote transaction type.
   * @param string $remote_status
   *   The Sage Payment transaction status.
   *
   * @return \Drupal\state_machine\Plugin\Workflow\WorkflowTransition
   *   The transition.
   *
   * @I Throw exception if there are more than one transition
   *    type     : bug
   *    priority : low
   *    label    : transaction, workflow
   */
  protected function getTransactionTransition(
    StateItemInterface $field_item,
    string $remote_type,
    string $remote_status
  ): WorkflowTransition {
    $to_state_id = strtolower($remote_type . '_' . $remote_status);
    foreach ($field_item->getTransitions() as $transition) {
      $to_state = $transition->getToState();
      if ($to_state->getId() === $to_state_id) {
        return $transition;
      }
    }

    throw new \RuntimeException(sprintf(
      'No transition found for target state "%s"',
      strtolower($remote_status)
    ));
  }

  /**
   * Returns the Direct API client for the given API ID.
   *
   * The client configuration is built from the transaction's parent payment
   * gateway plugin configuration.
   *
   * @param string $api_id
   *   The API ID for which to build the client.
   * @param \Drupal\commerce_transaction\Entity\TransactionInterface $transaction
   *   The transaction.
   *
   * @return \KrystalCode\SagePayments\Sdk\DirectApi\ClientInterface
   *   The API client.
   */
  protected function getApiClient(
    string $api_id,
    TransactionInterface $transaction
  ): ClientInterface {
    $gateway_plugin = $this->getTransactionGatewayPlugin($transaction);
    $plugin_configuration = $gateway_plugin->getConfiguration();
    $client_configuration = [
      'client_id' => $plugin_configuration['credentials']['client_id'],
      'client_secret' => $plugin_configuration['credentials']['client_secret'],
      'merchant_id' => $plugin_configuration['credentials']['merchant_id'],
      'merchant_key' => $plugin_configuration['credentials']['merchant_key'],
    ];

    if ($gateway_plugin->getMode() === 'live') {
      $client_configuration['env'] = ClientBase::ENV_PRODUCTION;
    }

    return $this->clientFactory->get(
      $api_id,
      $client_configuration
    );
  }

}