contacts_subscriptions-1.x-dev/src/Form/SubscriptionPaymentForm.php

src/Form/SubscriptionPaymentForm.php
<?php

namespace Drupal\contacts_subscriptions\Form;

use CommerceGuys\Intl\Formatter\CurrencyFormatterInterface;
use Drupal\commerce\InlineFormManager;
use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\commerce_payment\Entity\PaymentGatewayInterface;
use Drupal\commerce_payment\Exception\DeclineException;
use Drupal\commerce_payment\Exception\InvalidRequestException as CommerceInvalidRequestException;
use Drupal\commerce_payment\PaymentOption;
use Drupal\commerce_payment\PaymentOptionsBuilderInterface;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsCreatingPaymentMethodsInterface;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsStoredPaymentMethodsInterface;
use Drupal\commerce_product\Entity\ProductVariationInterface;
use Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer\PromotionOfferInterface;
use Drupal\commerce_stripe\Plugin\Commerce\PaymentGateway\StripeInterface;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Component\Utility\NestedArray;
use Drupal\contacts_subscriptions\InvoiceManager;
use Drupal\contacts_subscriptions\SubscriptionsHelper;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
use Drupal\Core\Extension\ModuleHandler;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface;
use Drupal\user\UserInterface;
use Stripe\Customer;
use Stripe\Exception\ApiErrorException;
use Stripe\Exception\InvalidRequestException as StripeInvalidRequestException;
use Stripe\PaymentIntent;
use Stripe\PaymentMethod;
use Stripe\SetupIntent;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

/**
 * Provides a Contacts Jobs Subscriptions form.
 */
class SubscriptionPaymentForm extends FormBase {

  use SubscriptionFormTrait;
  use DependencySerializationTrait;

  /**
   * The currency formatter.
   *
   * @var \CommerceGuys\Intl\Formatter\CurrencyFormatterInterface
   */
  protected CurrencyFormatterInterface $currencyFormatter;

  /**
   * The payment options builder.
   *
   * @var \Drupal\commerce_payment\PaymentOptionsBuilderInterface
   */
  protected PaymentOptionsBuilderInterface $paymentOptionsBuilder;

  /**
   * The inline form manager.
   *
   * @var \Drupal\commerce\InlineFormManager
   */
  protected InlineFormManager $inlineFormManager;

  /**
   * The event dispatcher.
   *
   * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
   */
  protected EventDispatcherInterface $eventDispatcher;

  /**
   * The job board commerce config.
   *
   * @var \Drupal\Core\Config\ImmutableConfig
   */
  protected ImmutableConfig $config;

  /**
   * The current user.
   *
   * @var \Drupal\Core\Session\AccountProxyInterface
   */
  protected AccountProxyInterface $currentUser;

  /**
   * The invoice manager.
   *
   * @var \Drupal\contacts_subscriptions\InvoiceManager
   */
  protected InvoiceManager $invoiceManager;

  /**
   * The time service.
   *
   * @var \Drupal\Component\Datetime\TimeInterface
   */
  protected TimeInterface $time;

  /**
   * The order entity.
   *
   * @var \Drupal\commerce_order\Entity\OrderInterface|null
   */
  protected ?OrderInterface $order = NULL;

  /**
   * The date formatter.
   *
   * @var \Drupal\Core\Datetime\DateFormatterInterface
   */
  protected DateFormatterInterface $dateFormatter;

  /**
   * Module Handler.
   *
   * @var \Drupal\Core\Extension\ModuleHandler
   */
  protected ModuleHandler $moduleHandler;

  /**
   * The subscriptions helper.
   *
   * @var \Drupal\contacts_subscriptions\SubscriptionsHelper
   */
  protected SubscriptionsHelper $subscriptionsHelper;

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    $form = new static();
    $form->csrf = $container->get('csrf_token');
    $form->entityTypeManager = $container->get('entity_type.manager');
    $form->currencyFormatter = $container->get('commerce_price.currency_formatter');
    $form->paymentOptionsBuilder = $container->get('commerce_payment.options_builder');
    $form->inlineFormManager = $container->get('plugin.manager.commerce_inline_form');
    $form->eventDispatcher = $container->get('event_dispatcher');
    $form->config = $container->get('config.factory')->get('contacts_jobs_commerce.settings');
    $form->currentUser = $container->get('current_user');
    $form->invoiceManager = $container->get('contacts_subscriptions.invoice_manager');
    $form->logger = $container->get('logger.channel.contacts_subscriptions');
    $form->time = $container->get('datetime.time');
    $form->setRequestStack($container->get('request_stack'));
    $form->dateFormatter = $container->get('date.formatter');
    $form->moduleHandler = $container->get('module_handler');
    $form->subscriptionsHelper = $container->get('contacts_subscriptions.helper');

    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function getFormId() {
    return 'contacts_subscription_payment';
  }

  /**
   * Build the form.
   *
   * @param array $form
   *   An associative array containing the structure of the form.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The current state of the form.
   * @param \Drupal\user\UserInterface|null $user
   *   The organisation user entity.
   * @param \Drupal\commerce_product\Entity\ProductVariationInterface|null $commerce_product_variation
   *   The product variation entity.
   * @param string|null $token
   *   The CSRF token.
   *
   * @return array
   *   The form array.
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   * @throws \Drupal\Core\Entity\EntityStorageException
   * @throws \Drupal\Core\Form\EnforcedResponseException
   * @throws \Drupal\Core\TypedData\Exception\MissingDataException
   */
  public function buildForm(array $form, FormStateInterface $form_state, UserInterface $user = NULL, ?ProductVariationInterface $commerce_product_variation = NULL, ?string $token = NULL) {
    $form['#attached']['library'][] = 'core/drupal.form';

    $this->initSubscription($user, FALSE, $commerce_product_variation);

    if ($commerce_product_variation != NULL && !$this->subscription->canChangeProduct()) {
      throw new AccessDeniedHttpException('Membership cannot be changed multiple times.');
    }

    $this->initVariation($commerce_product_variation, $token, TRUE);
    $order = $this->getOrder($form_state);

    $form['#id'] = 'subscription-payment-form';
    $form['#parents'] = [];

    $price = $order->getTotalPrice();

    // If we were given a standard variation ID in the URL, use that for the
    // standard price.
    if ($this->getRequest()->query->has('vid')) {
      /** @var \Drupal\commerce_product\Entity\ProductVariationInterface $standard_variation */
      $standard_variation = $this->entityTypeManager
        ->getStorage('commerce_product_variation')
        ->load($this->getRequest()->query->get('vid'));
      $standard_price = $standard_variation->getPrice();
    }
    // Otherwise, recalculate it!
    else {
      $products = $this->subscriptionsHelper->getProducts($user);
      $standard_price = $products[$this->productVariation->getProductId()]->getPrice();
    }

    $duration = $this->productVariation->subscription_length->value;
    $start_date = $this->subscription->isActive() ?
      $this->subscription->getRenewalDate(FALSE) :
      DrupalDateTime::createFromTimestamp($this->time->getRequestTime(), 'UTC');
    $renewal = (clone $start_date)->add(new \DateInterval("P{$duration}M"));

    $form['subscription'] = [
      '#type' => 'html_tag',
      '#tag' => 'h3',
    ];
    $form['offer'] = [
      '#type' => 'html_tag',
      '#tag' => 'h5',
      '#access' => FALSE,
    ];

    if (!$price->equals($standard_price) || $offer = $this->getCouponOfferFromOrder($order)) {
      $form['offer']['#access'] = TRUE;

      // @todo Implement a custom offer type for additional months.
      if (!empty($offer) && $offer->getPluginId() === 'order_buy_x_get_y') {
        $adjusted_duration = $offer->getConfiguration()['get_quantity'];
        $addition_duration = $adjusted_duration - $duration;
        $form['offer']['#value'] = $this->t(
          'Includes @additional_duration free. Your next renewal will be for @original_duration.',
          [
            '@additional_duration' => $this->formatPlural($addition_duration, '1 month', '@count months'),
            '@original_duration' => $this->formatPlural($duration, '1 month', '@count months'),
          ],
        );
        $duration = $adjusted_duration;
        $renewal = (clone $start_date)->add(new \DateInterval("P{$duration}M"));
      }
      // @todo Implement a custom offer type for discount on shortened intro
      //   period.
      else {
        $adjusted_duration = 1;
        $renewal = (clone $start_date)->add(new \DateInterval("P{$adjusted_duration}M"));

        $price_text = $price->isPositive() ?
          $this->currencyFormatter->format($price->getNumber(), $price->getCurrencyCode()) :
          $this->t('free');
        $intro_text = $this->t('Your first @adjusted_duration is @price.', [
          '@adjusted_duration' => $this->formatPlural($adjusted_duration, 'month', '@count months'),
          '@price' => $price_text,
        ]);

        $form['offer']['#value'] = new FormattableMarkup('@intro_text @renewal_text', [
          '@intro_text' => $intro_text,
          '@renewal_text' => $this->t('You will be charged @standard_price for @standard_duration on @renewal.', [
            '@standard_price' => $this->currencyFormatter->format($standard_price->getNumber(), $standard_price->getCurrencyCode()),
            '@standard_duration' => $this->formatPlural($duration, 'month', '@count months'),
            '@renewal' => $this->dateFormatter->format($renewal->getTimestamp(), 'short_date'),
          ]),
        ]);

        $duration = $adjusted_duration;
      }
    }

    $form['renewal'] = [
      '#type' => 'value',
      '#value' => $renewal->format(DateTimeItemInterface::DATE_STORAGE_FORMAT),
    ];

    $label = '';

    if ($order_items = $order->getItems()) {
      if ($item = reset($order_items)) {
        $label = $item->label();
      }
    }
    $form['subscription']['#value'] = $this->t('You are signing up to %label for @duration at @price', [
      '%label' => $label,
      '@duration' => $this->formatPlural($duration, '1 month', '@count months'),
      '@price' => $this->currencyFormatter->format($standard_price->getNumber(), $standard_price->getCurrencyCode()),
    ]);

    // If this user doesn't have an active (including payment failed)
    // subscription, allow them to enter a coupon code. This means that the
    // update payment details does not show the coupon code, but if a
    // subscription expires it will. Coupon codes should be configured as one
    // per user a user shouldn't be able to get a subsequent discount.
    if ($this->moduleHandler->moduleExists('commerce_coupon') && !$this->subscription->isActive()) {
      $form['coupon'] = [
        '#parents' => ['coupon'],
      ];
      $inline_form = $this->inlineFormManager->createInstance('coupon_redemption', [
        'order_id' => $order->id(),
        'max_coupons' => 1,
      ]);
      $form['coupon'] = $inline_form->buildInlineForm($form['coupon'], $form_state);
    }

    /** @var \Drupal\commerce_payment\PaymentGatewayStorageInterface $payment_gateway_storage */
    $payment_gateway_storage = $this->entityTypeManager->getStorage('commerce_payment_gateway');
    // Load the payment gateways. This fires an event for filtering the
    // available gateways, and then evaluates conditions on all remaining ones.
    $payment_gateways = $payment_gateway_storage->loadMultipleForOrder($order);
    // Can't proceed without any payment gateways.
    if (empty($payment_gateways)) {
      $this->messenger()->addError($this->t('There are no payment gateways available for this order. Please try again later.'));
      return $form;
    }

    // Core bug #1988968 doesn't allow the payment method add form JS to depend
    // on an external library, so the libraries need to be preloaded here.
    foreach ($payment_gateways as $payment_gateway) {
      if ($js_library = $payment_gateway->getPlugin()->getJsLibrary()) {
        $form['#attached']['library'][] = $js_library;
      }
    }

    $options = $this->paymentOptionsBuilder->buildOptions($order, $payment_gateways);

    // Only stored payment methods are suitable for subscriptions.
    $options = array_filter($options, function (PaymentOption $option) use ($payment_gateways) {
      return $payment_gateways[$option->getPaymentGatewayId()]->getPlugin() instanceof SupportsStoredPaymentMethodsInterface;
    });

    $option_labels = array_map(function (PaymentOption $option) {
      return $option->getLabel();
    }, $options);

    $default_option_id = NestedArray::getValue($form_state->getUserInput(), ['payment_method']);
    if ($default_option_id && isset($options[$default_option_id])) {
      $default_option = $options[$default_option_id];
    }
    else {
      $default_option = $this->paymentOptionsBuilder->selectDefaultOption($order, $options);
    }

    $form['payment_method'] = [
      '#type' => 'radios',
      '#title' => $this->t('Payment method'),
      '#options' => $option_labels,
      '#default_value' => $default_option->getId(),
      '#ajax' => [
        'callback' => [get_class($this), 'ajaxRefresh'],
        'wrapper' => $form['#id'],
      ],
      '#access' => count($options) > 1,
    ];
    // Add a class to each individual radio, to help themers.
    foreach ($options as $option) {
      $class_name = $option->getPaymentMethodId() ? 'stored' : 'new';
      $form['payment_method'][$option->getId()]['#attributes']['class'][] = "payment-method--$class_name";
    }
    // Store the options for submitPaneForm().
    $form['#payment_options'] = $options;

    $default_payment_gateway_id = $default_option->getPaymentGatewayId();
    $payment_gateway = $payment_gateways[$default_payment_gateway_id];
    if ($payment_gateway->getPlugin() instanceof SupportsStoredPaymentMethodsInterface) {
      $form = $this->buildPaymentMethodForm($form, $form_state, $default_option, $order);
    }
    else {
      $form = $this->buildBillingProfileForm($form, $form_state, $order);
    }

    // If we have an error from the payment method form, we can present a
    // malformed form to the user that errors on submit. So only add the submit
    // if this error is not present.
    if (!isset($form['add_payment_method']['#message_list']['error'])) {
      $form['actions'] = ['#type' => 'actions'];
      $form['actions']['submit'] = [
        '#type' => 'submit',
        '#value' => $price->isZero() ? $this->t('Register card') : $this->t('Make payment'),
        '#button_type' => 'primary',
        '#states' => [
          'disabled' => [
            ':input[name="coupon[code]"]' => ['empty' => FALSE],
          ],
        ],
      ];
    }
    $form['#cache'] = [
      'contexts' => ['user', 'url.query_args'],
    ];

    return $form;
  }

  /**
   * Get the order we are processing.
   *
   * This could be one of a few different things:
   * - If there is an active subscription with a failed payment, it will be the
   *   most recent order. Note, it will not change the existing product
   *   variation.
   * - If there is not an active subscription, a new order will be created, but
   *   not saved.
   *
   * @param \Drupal\Core\Form\FormStateInterface|null $form_state
   *   A Drupal form state.
   *
   * @return \Drupal\commerce_order\Entity\OrderInterface
   *   The order to be processed.
   *
   * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
   *   Thrown if there is an active subscription for a different product
   *   variation from that requested.
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   * @throws \Drupal\Core\TypedData\Exception\MissingDataException
   */
  protected function getOrder(FormStateInterface $form_state = NULL): OrderInterface {
    if ($this->subscription->isActive()) {
      // If a payment has failed because of a payment error, we will use the
      // existing order if we can find it.
      if (!$this->subscription->isCancelPending() && $this->subscription->needsPaymentDetails()) {
        if ($order = $this->findExistingOrder([
          'payment_declined',
          'payment_error',
        ],
          FALSE,
        )) {
          return $order;
        }
      }
    }

    // If we have gotten to here, first look for an existing draft order that
    // we'll update.
    if ($order = $this->findExistingOrder(['draft'], TRUE)) {
      return $order;
    }

    // Otherwise, create an order.
    return $this->invoiceManager->createOrder($this->subscription, $this->productVariation, FALSE);
  }

  /**
   * Helper to load coupons from an order.
   *
   * @param \Drupal\commerce_order\Entity\OrderInterface|null $order
   *   An order to load the coupons from.
   *
   * @return \Drupal\commerce_promotion\Entity\CouponInterface[]|null
   *   Any referenced entities from the order's coupons field.
   */
  protected function getCouponOfferFromOrder(OrderInterface $order): ?PromotionOfferInterface {
    if (!$this->moduleHandler->moduleExists('commerce_coupon')) {
      return NULL;
    }

    if ($order->get('coupons')->isEmpty()) {
      return NULL;
    }
    /** @var \Drupal\commerce_promotion\Entity\CouponInterface $coupon */
    if ($coupons = $order->get('coupons')->referencedEntities()) {
      if ($coupon = $coupons[0]) {
        return $coupon->getPromotion()->getOffer();
      }
    }

    return NULL;
  }

  /**
   * Builds the payment method form for the selected payment option.
   *
   * @param array $form
   *   The pane form.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The form state of the parent form.
   * @param \Drupal\commerce_payment\PaymentOption $payment_option
   *   The payment option.
   * @param \Drupal\commerce_order\Entity\OrderInterface $order
   *   The order entity.
   *
   * @return array
   *   The modified pane form.
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   * @throws \Drupal\Core\Entity\EntityStorageException
   * @throws \Stripe\Exception\ApiErrorException
   */
  protected function buildPaymentMethodForm(array $form, FormStateInterface $form_state, PaymentOption $payment_option, OrderInterface $order) {
    $amount = $order->getBalance();

    if ($payment_option->getPaymentMethodId() && !$payment_option->getPaymentMethodTypeId()) {
      // Nothing to do if we are not collecting payment.
      if (!$amount || !$amount->isPositive()) {
        return $form;
      }

      // If we are collecting any payment immediately, provide the client secret
      // if it is a stripe payment, as we may need to re-run SCA.
      /** @var \Drupal\commerce_payment\Entity\PaymentMethodInterface $payment_method */
      $payment_method = $this->entityTypeManager
        ->getStorage('commerce_payment_method')
        ->load($payment_option->getPaymentMethodId());

      $gateway = $payment_method->getPaymentGateway();
      $gateway_plugin = $gateway->getPlugin();

      try {
        if ($gateway_plugin instanceof StripeInterface) {
          // Get or set up a payment intent to be validated.
          if ($intent_id = $order->getData('stripe_intent')) {
            $intent = PaymentIntent::retrieve($intent_id);
          }

          if (empty($intent) || $payment_method->getRemoteId() !== $intent->payment_method) {
            $order->set('payment_method', $payment_method);
            $intent = $gateway_plugin->createPaymentIntent($order, FALSE);
          }
          $form['#attached']['library'][] = 'commerce_stripe/stripe';
          $form['#attached']['library'][] = 'commerce_stripe/checkout_review';
          $form['#attached']['drupalSettings']['commerceStripe'] = [
            'publishableKey' => $gateway_plugin->getPublishableKey(),
            'clientSecret' => $intent->client_secret,
            'buttonId' => 'edit-submit',
            'orderId' => $order->id(),
            'paymentMethod' => $intent->payment_method,
          ];
        }
      }
      // Catch an error with stripe and fall through the payment method
      // selection form.
      catch (CommerceInvalidRequestException $exception) {
      }

      // Editing payment methods at checkout is not supported.
      return $form;
    }

    // Clear the stripe button ID as the form and review js clash.
    $form['#attached']['drupalSettings']['commerceStripe']['buttonId'] = NULL;

    /** @var \Drupal\commerce_payment\PaymentMethodStorageInterface $payment_method_storage */
    $payment_method_storage = $this->entityTypeManager->getStorage('commerce_payment_method');
    $payment_method = $payment_method_storage->create([
      'type' => $payment_option->getPaymentMethodTypeId(),
      'payment_gateway' => $payment_option->getPaymentGatewayId(),
      'uid' => $order->getCustomerId(),
      'billing_profile' => $order->getBillingProfile(),
      'reusable' => TRUE,
      'is_default' => TRUE,
    ]);
    $inline_form = $this->inlineFormManager->createInstance('payment_gateway_form', [
      'operation' => 'add-payment-method',
    ], $payment_method);

    $form['add_payment_method'] = [
      '#parents' => array_merge($form['#parents'], ['add_payment_method']),
      '#inline_form' => $inline_form,
      '#allow_reusable' => TRUE,
      '#always_save' => TRUE,
      '#free_orders' => TRUE,
    ];
    $form['add_payment_method'] = $inline_form->buildInlineForm($form['add_payment_method'], $form_state);

    /** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface $gateway */
    $gateway = $this->entityTypeManager
      ->getStorage('commerce_payment_gateway')
      ->load($payment_option->getPaymentGatewayId());
    $gateway_plugin = $gateway->getPlugin();
    if ($gateway_plugin instanceof StripeInterface) {
      try {
        // If we are not collecting payment now, we want to use a SetupIntent.
        if (!$amount || !$amount->isPositive()) {
          $intent = SetupIntent::create([
            'usage' => 'off_session',
            'customer' => $this->getRemoteCustomerId($this->getOrder()->getCustomer(), $gateway),
          ]);
        }
        // Otherwise, we want a PaymentIntent.
        else {
          if ($intent_id = $order->getData('stripe_intent')) {
            $intent = PaymentIntent::retrieve($intent_id);
          }
          else {
            $amount = $order->getBalance();
            $intent_array = [
              'amount' => $gateway_plugin->toMinorUnits($amount),
              'currency' => $amount->getCurrencyCode(),
              'payment_method_types' => ['card'],
              'metadata' => [
                'order_id' => $order->id(),
                'store_id' => $order->getStoreId(),
              ],
              'capture_method' => 'manual',
              'customer' => $this->getRemoteCustomerId($this->getOrder()->getCustomer(), $gateway),
            ];
            $intent = PaymentIntent::create($intent_array);
            $order->setData('stripe_intent', $intent->id)->save();
          }
        }
        $form['add_payment_method']['payment_details']['#attached']['drupalSettings']['commerceStripe']['clientSecret'] = $intent->client_secret;
        $form['add_payment_method']['payment_details']['#attached']['drupalSettings']['commerceStripe']['futureUsage'] = TRUE;
      }
      catch (ApiErrorException $e) {
        $this->logger('commerce_stripe')->warning($e->getMessage());
        $form['add_payment_method'] = [
          '#theme' => 'status_messages',
          '#message_list' => [
            'error' => [$this->t('Sorry, we encountered an unexpected error. You have not been charged. Please try again later or get in touch.')],
          ],
        ];
      }
    }

    return $form;
  }

  /**
   * Builds the billing profile form.
   *
   * @param array $form
   *   The pane form.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The form state of the parent form.
   * @param \Drupal\commerce_order\Entity\OrderInterface $order
   *   The order entity.
   *
   * @return array
   *   The modified pane form.
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   */
  protected function buildBillingProfileForm(array $form, FormStateInterface $form_state, OrderInterface $order) {
    $billing_profile = $order->getBillingProfile();
    if (!$billing_profile) {
      $billing_profile = $this->entityTypeManager->getStorage('profile')->create([
        'uid' => $order->getCustomerId(),
        'type' => 'customer',
      ]);
    }
    $inline_form = $this->inlineFormManager->createInstance('customer_profile', [
      'profile_scope' => 'billing',
      'available_countries' => $order->getStore()->getBillingCountries(),
    ], $billing_profile);

    $form['billing_information'] = [
      '#parents' => array_merge($form['#parents'] ?? [], ['billing_information']),
      '#inline_form' => $inline_form,
    ];
    $form['billing_information'] = $inline_form->buildInlineForm($form['billing_information'], $form_state);

    return $form;
  }

  /**
   * Ajax callback.
   */
  public static function ajaxRefresh(array $form, FormStateInterface $form_state) {
    $parents = $form_state->getTriggeringElement()['#parents'];
    array_pop($parents);
    return NestedArray::getValue($form, $parents);
  }

  /**
   * {@inheritdoc}
   */
  public function validateForm(array &$form, FormStateInterface $form_state) {
    if (!$this->subscription) {
      $this->logger->critical('Missing form cache for subscription payment.');
      $form_state->setError(
        $form,
        new TranslatableMarkup(
          'Sorry, something went wrong processing your payment. Please <a href="@link">click here</a> to try again.',
          ['@link' => $this->getRequest()->getRequestUri()],
        ),
      );
    }
  }

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {
    $order = $this->getOrder();

    // Set the renewal on the order data.
    // @see \Drupal\contacts_subscriptions\EventSubscriber\OrderSubscriber::subscriptionActivate
    $order->setData('contacts_subscription_renewal', $form_state->getValue('renewal'));
    $order->subscription = $this->subscription;

    if (isset($form['billing_information']['#inline_form'])) {
      /** @var \Drupal\commerce\Plugin\Commerce\InlineForm\EntityInlineFormInterface $inline_form */
      $inline_form = $form['billing_information']['#inline_form'];
      /** @var \Drupal\profile\Entity\ProfileInterface $billing_profile */
      $billing_profile = $inline_form->getEntity();
      if ($order->isPaid() || $order->getTotalPrice()->isZero()) {
        $order->setBillingProfile($billing_profile);
        $order->save();
        $form_state->setRedirectUrl($this->getRedirectUrl($order, TRUE));
        return;
      }
    }

    $values = $form_state->getValues();
    /** @var \Drupal\commerce_payment\PaymentOption $selected_option */
    $selected_option = $form['#payment_options'][$values['payment_method']];
    /** @var \Drupal\commerce_payment\PaymentGatewayStorageInterface $payment_gateway_storage */
    $payment_gateway_storage = $this->entityTypeManager->getStorage('commerce_payment_gateway');
    /** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface $payment_gateway */
    $payment_gateway = $payment_gateway_storage->load($selected_option->getPaymentGatewayId());
    if (!$payment_gateway) {
      return;
    }

    $payment_method_storage = $this->entityTypeManager->getStorage('commerce_payment_method');
    $payment_gateway_plugin = $payment_gateway->getPlugin();
    if ($payment_gateway_plugin instanceof SupportsCreatingPaymentMethodsInterface) {
      if (!empty($selected_option->getPaymentMethodTypeId())) {
        /** @var \Drupal\commerce\Plugin\Commerce\InlineForm\EntityInlineFormInterface $inline_form */
        $inline_form = $form['add_payment_method']['#inline_form'];
        // The payment method was just created.
        /** @var \Drupal\commerce_payment\Entity\PaymentMethodInterface $payment_method */
        $payment_method = $inline_form->getEntity();

        if ($payment_gateway_plugin instanceof StripeInterface) {
          $method = PaymentMethod::retrieve($payment_method->getRemoteId());
          $customer_id = $this->getRemoteCustomerId($order->getCustomer(), $payment_gateway);
          $method->attach(['customer' => $customer_id]);
        }
      }
      else {
        /** @var \Drupal\commerce_payment\PaymentMethodStorageInterface $payment_method_storage */
        $payment_method_storage = $this->entityTypeManager->getStorage('commerce_payment_method');
        $payment_method = $payment_method_storage->load($selected_option->getPaymentMethodId());
      }

      /** @var \Drupal\commerce_payment\Entity\PaymentMethodInterface $payment_method */
      $order->set('payment_gateway', $payment_method->getPaymentGateway());
      $order->set('payment_method', $payment_method);
      // Copy the billing information to the order.
      $payment_method_profile = $payment_method->getBillingProfile();
      if ($payment_method_profile) {
        $billing_profile = $order->getBillingProfile();
        if (!$billing_profile) {
          $billing_profile = $this->entityTypeManager->getStorage('profile')->create([
            'type' => 'customer',
            'uid' => 0,
          ]);
        }
        $billing_profile->populateFromProfile($payment_method_profile);
        // The data field is not copied by default but needs to be.
        // For example, both profiles need to have an address_book_profile_id.
        $billing_profile->populateFromProfile($payment_method_profile, ['data']);
        $billing_profile->save();
        $order->setBillingProfile($billing_profile);
      }
    }
    elseif ($payment_gateway_plugin instanceof SupportsStoredPaymentMethodsInterface) {
      if (!empty($selected_option->getPaymentMethodTypeId())) {
        /** @var \Drupal\commerce\Plugin\Commerce\InlineForm\EntityInlineFormInterface $inline_form */
        $inline_form = $form['add_payment_method']['#inline_form'];
        // The payment method was just created.
        $payment_method = $inline_form->getEntity();
      }
      else {
        /** @var \Drupal\commerce_payment\PaymentMethodStorageInterface $payment_method_storage */
        $payment_method = $payment_method_storage->load($selected_option->getPaymentMethodId());
      }

      /** @var \Drupal\commerce_payment\Entity\PaymentMethodInterface $payment_method */
      $order->set('payment_gateway', $payment_method->getPaymentGateway());
      $order->set('payment_method', $payment_method);
      $order->setBillingProfile($payment_method->getBillingProfile());
    }
    else {
      throw new \Exception('Payment gateway not valid for subscriptions.');
    }

    // Un-default any other payment methods and set this as default if we
    // created a new one.
    if ($existing_id = $selected_option->getPaymentMethodId()) {
      /** @var \Drupal\commerce_payment\Entity\PaymentMethodInterface $existing_method */
      $existing_method = $payment_method_storage->load($existing_id);
      if (!$existing_method->isDefault()) {
        $existing_method->setDefault(TRUE)->save();
      }
    }
    /** @var \Drupal\commerce_payment\Entity\PaymentMethodInterface[] $other_methods */
    $other_methods = $payment_method_storage->loadByProperties([
      'uid' => $this->user->id(),
      'is_default' => TRUE,
    ]);
    foreach ($other_methods as $other_method) {
      if ($existing_id && $other_method->id() == $existing_id) {
        continue;
      }
      $other_method->setDefault(FALSE)->save();
    }

    $order->save();

    // If the order is zero value, we can complete it and move on.
    $amount = $order->getBalance();
    if (!$amount || !$amount->isPositive()) {
      $order->getState()->applyTransitionById('place');
      $order->save();

      $this->messenger()->addStatus($this->t('Your membership has been activated.'));
      $this->gaPush($order);
      $form_state->setRedirectUrl($this->getRedirectUrl($order, TRUE));
      return;
    }

    // Attempt to process the payment.
    /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */
    $payment = $this->entityTypeManager
      ->getStorage('commerce_payment')
      ->create([
        'state' => 'new',
        'amount' => $amount,
        'payment_gateway' => $payment_gateway->id(),
        'order_id' => $order->id(),
      ]);
    $payment->set('payment_method', $order->get('payment_method')->entity);
    try {
      if ($payment_gateway_plugin instanceof StripeInterface) {
        if ($intent_id = $order->getData('stripe_intent')) {
          $intent = PaymentIntent::retrieve($intent_id);

          if ($intent->status !== 'succeeded') {
            $intent->capture();
          }
        }
        else {
          $payment_gateway_plugin->createPaymentIntent($order);
        }
      }

      $payment_gateway_plugin->createPayment($payment);

      // Re-load the order as it may have been modified by the payment gateway.
      /** @var \Drupal\commerce_order\Entity\OrderInterface $order */
      $order = $this->entityTypeManager
        ->getStorage('commerce_order')
        ->loadUnchanged($order->id());

      $order->save();
      $this->gaPush($order);

      if ($payment->isCompleted()) {
        $this->messenger()->addStatus($this->t('Thank you for your payment. Your membership has been activated.'));
      }
      else {
        $this->messenger()->addWarning($this->t('Your payment is taking longer than expected to complete. Your membership will be activated when it has completed.'));
      }
      $form_state->setRedirectUrl($this->getRedirectUrl($order, TRUE));
    }
    catch (DeclineException $e) {
      $this->messenger()->addError($this->t('Your payment was declined. Please check your details and try again.'));
      $form_state->setRebuild();
    }
    catch (StripeInvalidRequestException $e) {
      $this->logger->error($e->getMessage());

      if ($payment->isCompleted() || (isset($intent) && $intent->status == 'succeeded')) {
        $this->messenger()->addError($this->t('Sorry, we encountered an unexpected error. Your payment has been taken so please DO NOT try again or you may be charged twice. Please contact us quoting Order #@id to resolve your issue.', [
          '@id' => $order->id(),
        ]));
        $form_state->setRedirectUrl($this->getRedirectUrl($order, TRUE));
      }
      else {
        $this->messenger()->addError($this->t('Sorry, we encountered an unexpected error. Please contact us quoting Order #@id to resolve your issue.', [
          '@id' => $order->id(),
        ]));
      }
      $form_state->setRedirectUrl($this->getRedirectUrl($order, FALSE));
    }
    catch (\Exception $e) {
      $this->logger->error($e->getMessage());

      if ($payment->isCompleted()) {
        $this->messenger()->addError($this->t('Sorry, we encountered an unexpected error. Your payment has been taken so please DO NOT try again or you may be charged twice. Please contact us quoting Order #@id to resolve your issue.', [
          '@id' => $order->id(),
        ]));
      }
      elseif ($e instanceof CommerceInvalidRequestException) {
        $this->messenger()->addError($this->t('Sorry, we encountered an unexpected error. You have not been charged. Please try again later or get in touch.'));
      }
      else {
        $this->messenger()->addError($this->t('Sorry, we encountered an unexpected error. Please try again later.'));
      }
      $form_state->setRedirectUrl($this->getRedirectUrl($order, FALSE));
    }
  }

  /**
   * Helper to get the correct URL to redirect the form to.
   *
   * @param \Drupal\commerce_order\Entity\OrderInterface $order
   *   The current order.
   * @param bool $success
   *   Whether the payment has suceeded.
   *
   * @return \Drupal\Core\Url
   *   The URl to redirect to.
   */
  private function getRedirectUrl(OrderInterface $order, bool $success): Url {
    $route = ($this->currentUser->hasPermission('access user membership tab')) ? 'contacts_subscriptions.manage' : 'user.page';
    $redirect = Url::fromRoute(
      $route,
      ['user' => $this->user->id()],
    );
    $data = [
      'subscription' => $this->subscription,
      'order' => $order,
      'success' => $success,
      'url' => &$redirect,
    ];

    $this->moduleHandler->invokeAll('contacts_subscriptions_alter_payment_redirect', $data);

    // If we have been provided a genuine Url, use it.
    if ($data['url'] instanceof Url) {
      return $data['url'];
    }

    return $redirect;
  }

  /**
   * Pushes data to GA via ga_push.
   *
   * @param \Drupal\commerce_order\Entity\OrderInterface $order
   *   The order to obtain data about.
   */
  protected function gaPush(OrderInterface $order) {
    if ($this->moduleHandler->moduleExists('ga_push')) {
      $push_items = [];
      $membership = NULL;
      $address = $order->getBillingProfile()->get('address')->getValue();

      foreach ($order->getItems() as $item) {
        /** @var \Drupal\commerce_product\Entity\ProductVariationInterface $variation */
        $variation = $item->getPurchasedEntity();
        $membership = $variation->getProduct()->getTitle();

        $push_items[] = [
          'order_id' => $order->getOrderNumber(),
          'sku'      => $variation->getSku(),
          'name'     => $membership,
          'category' => 'Membership',
          'price'    => $item->getTotalPrice()->getNumber(),
          'quantity' => $item->getQuantity(),
          'currency' => $item->getTotalPrice()->getCurrencyCode(),
        ];
      }

      /** @var \Drupal\commerce_order\Adjustment $tax */
      $adjustments = $order->getAdjustments(['tax']);
      $tax = ($adjustments) ? reset($adjustments) : NULL;

      ga_push_add_ecommerce([
        'trans' => [
          'order_id'       => $order->getOrderNumber(),
          'affiliation'    => $order->getStore()->getName(),
          'total'          => $order->getTotalPaid()->getNumber(),
          'total_tax'      => ($tax) ? $tax->getAmount() : 0,
          'total_shipping' => '0',
          'city'           => $address[0]['locality'],
          'region'         => $address[0]['administrative_area'],
          'country'        => $address[0]['country_code'],
          'currency'       => $order->getTotalPaid()->getCurrencyCode(),
        ],
        'items' => $push_items,
      ]);
      ga_push_add_event([
        'eventCategory' => 'Membership',
        'eventAction' => 'Membership',
        'eventLabel' => $membership,
        'eventValue' => 1,
        'non-interaction' => FALSE,
      ]);
    }
  }

  /**
   * Get the store ID for the job order.
   *
   * @return int|null
   *   The store ID, if there is one.
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   */
  protected function getStoreId(): ?int {
    // Check our config.
    if ($store_id = $this->config->get('store_id')) {
      return $store_id;
    }

    // Get the default.
    /** @var \Drupal\commerce_store\StoreStorageInterface $store_storage */
    $store_storage = $this->entityTypeManager->getStorage('commerce_store');
    if ($store = $store_storage->loadDefault()) {
      return $store->id();
    }

    return NULL;
  }

  /**
   * Find an existing order, including validating the variation.
   *
   * @param array $state
   *   The suitable order states.
   * @param bool $filter_variation
   *   Whether to filter the order by the desired variation.
   *
   * @return \Drupal\commerce_order\Entity\OrderInterface|null
   *   The order if we found one, NULL otherwise.
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   * @throws \Drupal\Core\TypedData\Exception\MissingDataException
   */
  protected function findExistingOrder(array $state, bool $filter_variation): ?OrderInterface {
    $order_storage = $this->entityTypeManager->getStorage('commerce_order');

    // To save having to mess around with the queries, we will not check access
    // here and instead check once we have a loaded order.
    $query = $order_storage->getQuery()
      ->condition('uid', $this->user->id())
      ->condition('type', 'contacts_subscription')
      ->condition('state', $state, 'IN')
      ->accessCheck(FALSE);

    if ($filter_variation) {
      $query->condition('order_items.entity.purchased_entity', $this->productVariation->id());
    }

    $ids = $query
      ->sort('placed', 'DESC')
      ->range(0, 1)
      ->execute();

    if ($ids) {
      /** @var \Drupal\commerce_order\Entity\OrderInterface $order */
      $order = $order_storage->load(reset($ids));

      // Validate the product.
      /** @var \Drupal\commerce_order\Entity\OrderItemInterface $order_item */
      if ($order_items = $order->getItems()) {
        if ($order_item = reset($order_items)) {
          $current_product = $order_item->getPurchasedEntityId();

          if ($current_product != $this->productVariation->id() || !$order->access('view', $this->currentUser)) {
            throw new AccessDeniedHttpException();
          }

          return $order;
        }
      }
    }

    return NULL;
  }

  /**
   * Gets the remote customer ID for the given user.
   *
   * The remote customer ID is specific to a payment gateway instance
   * in the configured mode. This allows the gateway to skip test customers
   * after the gateway has been switched to live mode.
   *
   * @param \Drupal\user\UserInterface $account
   *   The user account.
   * @param \Drupal\commerce_payment\Entity\PaymentGatewayInterface $gateway
   *   The gateway entity.
   *
   * @return string
   *   The remote customer ID, or NULL if none found.
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   * @throws \Stripe\Exception\ApiErrorException
   */
  protected function getRemoteCustomerId(UserInterface $account, PaymentGatewayInterface $gateway) {
    $gateway_plugin = $gateway->getPlugin();
    $remote_id = $gateway_plugin->getRemoteCustomerId($account);

    if (!$account->isAnonymous() && !$remote_id && $gateway_plugin instanceof StripeInterface) {

      // If we haven't got a remote ID, create a customer.
      $email = $account->getEmail();
      $customer = Customer::create([
        'email' => $email,
        'description' => $account->label(),
      ]);
      $remote_id = $customer->id;
      $account->get('commerce_remote_id')->setByProvider($gateway->id() . '|' . $gateway_plugin->getMode(), $remote_id);
      $account->save();
    }

    return $remote_id;
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc