contacts_subscriptions-1.x-dev/src/InvoiceManager.php
src/InvoiceManager.php
<?php namespace Drupal\contacts_subscriptions; use Drupal\commerce_order\Entity\OrderInterface; use Drupal\commerce_payment\Entity\PaymentMethodInterface; use Drupal\commerce_payment\Exception\DeclineException; use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsStoredPaymentMethodsInterface; use Drupal\commerce_product\Entity\ProductVariationInterface; use Drupal\commerce_stripe\Plugin\Commerce\PaymentGateway\StripeInterface; use Drupal\Component\Datetime\TimeInterface; use Drupal\contacts_subscriptions\Entity\SubscriptionInterface; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Config\ImmutableConfig; use Drupal\Core\Datetime\DateFormatterInterface; use Drupal\Core\Datetime\DrupalDateTime; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Logger\LoggerChannelInterface; use Drupal\Core\Session\AccountProxyInterface; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; use Drupal\user\UserInterface; /** * The Invoice Manager service. */ class InvoiceManager { /** * The entity type manager. * * @var \Drupal\Core\Entity\EntityTypeManagerInterface */ protected EntityTypeManagerInterface $entityTypeManager; /** * The time service. * * @var \Drupal\Component\Datetime\TimeInterface */ protected TimeInterface $time; /** * The date formatter service. * * @var \Drupal\Core\Datetime\DateFormatterInterface */ protected DateFormatterInterface $dateFormatter; /** * The contacts jobs commerce config. * * @var \Drupal\Core\Config\ImmutableConfig */ protected ImmutableConfig $config; /** * The current user. * * @var \Drupal\Core\Session\AccountProxyInterface */ protected AccountProxyInterface $currentUser; /** * The logger channel. * * @var \Drupal\Core\Logger\LoggerChannelInterface */ protected LoggerChannelInterface $logger; /** * The subscriptions helper. * * @var \Drupal\contacts_subscriptions\SubscriptionsHelper */ protected SubscriptionsHelper $subscriptionsHelper; /** * Constructs an InvoiceManager object. * * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager. * @param \Drupal\Component\Datetime\TimeInterface $time * The time service. * @param \Drupal\Core\Datetime\DateFormatterInterface $date_formatter * The date formatter service. * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory * The config factory. * @param \Drupal\Core\Session\AccountProxyInterface $current_user * The current user. * @param \Drupal\Core\Logger\LoggerChannelInterface $logger * The logger channel. * @param \Drupal\contacts_subscriptions\SubscriptionsHelper $subscriptions_helper * The subscriptions helper. */ public function __construct(EntityTypeManagerInterface $entity_type_manager, TimeInterface $time, DateFormatterInterface $date_formatter, ConfigFactoryInterface $config_factory, AccountProxyInterface $current_user, LoggerChannelInterface $logger, SubscriptionsHelper $subscriptions_helper) { $this->entityTypeManager = $entity_type_manager; $this->time = $time; $this->dateFormatter = $date_formatter; $this->config = $config_factory->get('contacts_jobs_commerce.settings'); $this->currentUser = $current_user; $this->logger = $logger; $this->subscriptionsHelper = $subscriptions_helper; } /** * Generate the subscription invoice for a user an organisation the status. * * @param \Drupal\contacts_subscriptions\Entity\SubscriptionInterface $subscription * The subscription entity. * @param \Drupal\commerce_product\Entity\ProductVariationInterface|null $variation * Optionally provide a variation to use. If not provided, the default * variation of the product from the profile will be used. If given, we will * set the product on the profile. * * @return bool * If the payment processing succeeded. * * @throws \InvalidArgumentException * Thrown if: * - $organisation is not an organisation * - $variation is not given and there is no variation set for the * organisation. * - $variation (given or retrieved) is not a subscription. */ public function generateInvoice(SubscriptionInterface $subscription, ProductVariationInterface $variation = NULL): bool { if (!$variation) { $variation = $this->subscriptionsHelper->getProductVariation($subscription); } if ($variation->getProduct()->bundle() !== 'subscription') { throw new \InvalidArgumentException("Product variation {$variation->id()} is not a subscription."); } $payment_needed = $variation->getPrice()->isPositive(); // If no payment is needed, update subscription and return. if (!$payment_needed) { $this->renewSubscription($subscription, $variation); return TRUE; } // Create the order. $order = $this->createOrder($subscription, $variation); $paid = $this->processPayment($order); // Don't update the subscription until a payment has succeeded. if ($paid) { $this->renewSubscription($subscription, $variation); } // If payment fails, return the variable to allow the code calling this // service to decide what to do. return $paid; } /** * Renew the subscription for another year. * * @param \Drupal\contacts_subscriptions\Entity\SubscriptionInterface $subscription * The subscription entity. * @param \Drupal\commerce_product\Entity\ProductVariationInterface $variation * The product variation. * * @throws \Drupal\Core\Entity\EntityStorageException */ protected function renewSubscription(SubscriptionInterface $subscription, ProductVariationInterface $variation): void { $start_date = $subscription->getRenewalDate() ?? DrupalDateTime::createFromTimestamp($this->time->getRequestTime()); $next_renewal_date = (clone $start_date)->add(new \DateInterval('P1Y')); // Update the subscription renewal and product on the profile. The state // will be handled by order state transitions. $subscription ->set('renewal', $next_renewal_date->format(DateTimeItem::DATE_STORAGE_FORMAT)) ->set('product', $variation->getProductId()); $subscription->setNewRevision(TRUE); $subscription->setRevisionCreationTime($this->time->getCurrentTime()); $subscription->setRevisionLogMessage('Subscription renewal generated.'); $subscription->save(); } /** * Get the store ID for the job order. * * @return int|null * The store ID, if there is one. * * @todo Abstract * \Drupal\contacts_jobs_commerce\Form\PaymentDetailsForm::getStoreId into * a trait or service that can be commonly used. */ 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; } /** * Create the new order. * * @param \Drupal\contacts_subscriptions\Entity\SubscriptionInterface $subscription * The user the order is for. * @param \Drupal\commerce_product\Entity\ProductVariationInterface $variation * The product variation to add to the order. * @param bool $place * Whether to place the order. * * @return \Drupal\commerce_order\Entity\OrderInterface * The generated order. */ public function createOrder(SubscriptionInterface $subscription, ProductVariationInterface $variation, bool $place = TRUE): OrderInterface { $user = $subscription->getOwner(); /** @var \Drupal\commerce_order\Entity\OrderInterface $order */ $order = $this->entityTypeManager ->getStorage('commerce_order') ->create([ 'type' => 'contacts_subscription', 'store_id' => $this->getStoreId(), 'state' => 'draft', 'uid' => $user->id(), 'mail' => $user->getEmail(), 'subscription' => $subscription->id(), ]); $order->save(); /** @var \Drupal\commerce_order\Entity\OrderItemInterface $order_item */ $order_item = $this->entityTypeManager ->getStorage('commerce_order_item') ->createFromPurchasableEntity($variation); $order_item->setQuantity(1); // Check for a discount. if ($discount = $subscription->getOverriddenPrice()) { $order_item->setUnitPrice($discount, TRUE); } $order_item->save(); $order->addItem($order_item); // Get the organisation's stored payment method. if ($method = $this->getPaymentMethod($user)) { if ($billing_profile = $method->getBillingProfile()) { $order->setBillingProfile($billing_profile); } $order->set('payment_method', $method); $order->set('payment_gateway', $method->getPaymentGateway()); } // If requested, apply the place transition. This will set the place // timestamp and order number and trigger relevant emails. if ($place) { $order->getState()->applyTransitionById('place'); } // Save the order. $order->save(); // Clear the discount if it has expired or has no expiry (one time). if ($subscription->hasOverrideExpired() !== FALSE) { $subscription ->set('price_override', NULL) ->set('price_override_date', NULL); } // Return the order. return $order; } /** * Attempt to process payment for the order. * * @param \Drupal\commerce_order\Entity\OrderInterface $order * The order. * * @return bool * Whether payment was successful. */ protected function processPayment(OrderInterface $order) { /** @var \Drupal\commerce_payment\Entity\PaymentMethodInterface $method */ $method = $order->get('payment_method')->entity; if (!$method) { $order->getState()->applyTransitionById('needs_payment'); $order->save(); return FALSE; } /** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface $payment_gateway */ $payment_gateway = $order->get('payment_gateway')->entity; $payment_gateway_plugin = $payment_gateway->getPlugin(); if (!($payment_gateway_plugin instanceof SupportsStoredPaymentMethodsInterface)) { $order->getState()->applyTransitionById('payment_error'); $order->save(); return FALSE; } /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */ $payment = $this->entityTypeManager ->getStorage('commerce_payment') ->create([ 'state' => 'new', 'amount' => $order->getBalance(), '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) { $payment_gateway_plugin->createPaymentIntent($order, TRUE, TRUE); } // Use the default capture behaviour for the payment method. $payment_gateway_plugin->createPayment($payment); if ($payment->isCompleted()) { $order->getState()->applyTransitionById('paid'); $order->save(); return TRUE; } } catch (DeclineException $e) { $order->getState()->applyTransitionById('payment_declined'); $order->save(); $payment->getState()->applyTransitionById('cs_failed_declined'); $payment->save(); } catch (\Exception $e) { $this->logger->error($e->getMessage()); $order->getState()->applyTransitionById('payment_error'); $order->save(); $payment->getState()->applyTransitionById('cs_failed_error'); $payment->save(); } return FALSE; } /** * Get the expiry date for a subscription with a failed payment. * * @param \Drupal\contacts_subscriptions\Entity\SubscriptionInterface $subscription * The subscription entity. * * @return \Drupal\Core\Datetime\DrupalDateTime|null * The expiry date, or NULl if there is none. */ public function getExpiry(SubscriptionInterface $subscription): ?DrupalDateTime { if (!$subscription->isActive() || !$subscription->needsPaymentDetails()) { return NULL; } $result = $this->entityTypeManager ->getStorage('commerce_order') ->getAggregateQuery() ->accessCheck(FALSE) ->condition('type', 'contacts_subscription') ->condition('state', ['payment_declined', 'payment_error'], 'IN') ->condition('subscription', $subscription->id()) ->aggregate('placed', 'MAX') ->execute(); if (empty($result[0]['placed_max'])) { return NULL; } return DrupalDateTime::createFromTimestamp($result[0]['placed_max']) ->add(new \DateInterval('PT72H')); } /** * Get the renewal payment method for a user. * * @param \Drupal\user\UserInterface $user * The user entity. * * @return \Drupal\commerce_payment\Entity\PaymentMethodInterface|null * The payment method, if there is one. */ public function getPaymentMethod(UserInterface $user): ?PaymentMethodInterface { $method_storage = $this->entityTypeManager->getStorage('commerce_payment_method'); $query = $method_storage->getQuery(); $query ->condition('uid', $user->id()) ->condition('reusable', TRUE) ->condition($query->orConditionGroup() ->condition('expires', $this->time->getRequestTime(), '>') ->condition('expires', 0)) ->sort('is_default', 'DESC') ->sort('method_id', 'DESC') ->range(0, 1); $ids = $query->execute(); return $ids ? $method_storage->load(reset($ids)) : NULL; } }