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