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;
}
}
