contacts_subscriptions-1.x-dev/src/EventSubscriber/OrderSubscriber.php
src/EventSubscriber/OrderSubscriber.php
<?php
namespace Drupal\contacts_subscriptions\EventSubscriber;
use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\commerce_order\Event\OrderEvent;
use Drupal\commerce_order\Event\OrderEvents;
use Drupal\commerce_product\Entity\ProductVariationInterface;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\contacts_subscriptions\Entity\SubscriptionInterface;
use Drupal\contacts_subscriptions\SubscriptionsHelper;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\Mail\MailManagerInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface;
use Drupal\state_machine\Event\WorkflowTransitionEvent;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Contacts Jobs Subscriptions order event subscriber.
*/
class OrderSubscriber implements EventSubscriberInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected EntityTypeManagerInterface $entityTypeManager;
/**
* The time service.
*
* @var \Drupal\Component\Datetime\TimeInterface
*/
protected TimeInterface $time;
/**
* The logger channel.
*
* @var \Drupal\Core\Logger\LoggerChannelInterface
*/
protected LoggerChannelInterface $logger;
/**
* The mail service.
*
* @var \Drupal\Core\Mail\MailManagerInterface
*/
protected MailManagerInterface $mailManager;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountProxyInterface
*/
private AccountProxyInterface $currentUser;
/**
* The subscription helper service.
*
* @var \Drupal\contacts_subscriptions\SubscriptionsHelper
*/
protected SubscriptionsHelper $subscriptionsHelper;
/**
* Constructs a new OrderSubscriber 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\Logger\LoggerChannelInterface $logger
* The logger channel.
* @param \Drupal\Core\Mail\MailManagerInterface $mail
* The mail service.
* @param \Drupal\Core\Session\AccountProxyInterface $current_user
* The current user.
* @param \Drupal\contacts_subscriptions\SubscriptionsHelper $subscriptions_helper
* The subscriptions helper service.
*/
public function __construct(
EntityTypeManagerInterface $entity_type_manager,
TimeInterface $time,
LoggerChannelInterface $logger,
MailManagerInterface $mail,
AccountProxyInterface $current_user,
SubscriptionsHelper $subscriptions_helper
) {
$this->entityTypeManager = $entity_type_manager;
$this->time = $time;
$this->logger = $logger;
$this->mailManager = $mail;
$this->currentUser = $current_user;
$this->subscriptionsHelper = $subscriptions_helper;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
// Trigger post place as early as possible. Non transition specific
// events happen later, so we'll add to the individual transitions.
return [
OrderEvents::ORDER_PAID => ['orderPaid'],
'commerce_order.paid.pre_transition' => [
['triggerPrePlace', 1000],
],
'commerce_order.payment_declined.pre_transition' => [
['triggerPostPlace', 1000],
],
'commerce_order.payment_error.pre_transition' => [
['triggerPostPlace', 1000],
],
'commerce_order.paid.post_transition' => [
['triggerPostPlace', 1000],
['subscriptionActivate'],
['notifyPaid', -200],
],
'commerce_order.payment_declined.post_transition' => [
['triggerPostPlace', 1000],
['subscriptionPaymentFailed'],
['notifyFailed', -200],
],
'commerce_order.payment_error.post_transition' => [
['triggerPostPlace', 1000],
['subscriptionPaymentFailed', 0],
['notifyFailed', -200],
],
'commerce_order.place.post_transition' => [
['clearRenewalProduct'],
],
];
}
/**
* Activate the subscription on successful payment.
*
* @param \Drupal\state_machine\Event\WorkflowTransitionEvent $event
* The workflow transition event.
*/
public function subscriptionActivate(WorkflowTransitionEvent $event): void {
if (!($order = $this->validateOrder($event))) {
return;
}
$subscription = $this->subscriptionsHelper->getSubscriptionFromOrder($order);
foreach ($order->getItems() as $item) {
$product_variation = $item->getPurchasedEntity();
if ($product_variation instanceof ProductVariationInterface && $product_variation->getProduct()->bundle() === 'subscription') {
$subscription->set('product', $product_variation->getProductId());
}
}
$renewal = $this->getRenewal($order, $subscription);
$subscription->set('renewal', $renewal->format(DateTimeItemInterface::DATE_STORAGE_FORMAT));
$subscription->setRevisionLogMessage("Subscription renewed");
if (!$this->applyTransitionIfAllowed(
$subscription,
'activate',
)) {
// If the transition has not been applied - for example, where a user has
// renewed a subscription before the expiry date - we will still need to
// save the subscription for the new renewal date to be stored.
$subscription->save();
}
}
/**
* Update the subscription on payment failures.
*
* @param \Drupal\state_machine\Event\WorkflowTransitionEvent $event
* The workflow transition event.
*/
public function subscriptionPaymentFailed(WorkflowTransitionEvent $event): void {
if (!($order = $this->validateOrder($event))) {
return;
}
$subscription = $this->subscriptionsHelper->getSubscriptionFromOrder($order);
$subscription->setRevisionLogMessage('Subscription payment failed');
$this->applyTransitionIfAllowed($subscription, 'payment_failed');
}
/**
* Ensure the order state is updated on payment completion.
*
* @param \Drupal\commerce_order\Event\OrderEvent $event
* The order event.
*/
public function orderPaid(OrderEvent $event): void {
$order = $event->getOrder();
if ($order->bundle() !== 'contacts_subscription') {
return;
}
// Apply the transition. This event happens in pre save, so we don't need to
// save the order.
$state = $order->getState();
if ($state->isTransitionAllowed('paid')) {
$state->applyTransitionById('paid');
}
}
/**
* Trigger the pre place event when it has been skipped.
*
* @param \Drupal\state_machine\Event\WorkflowTransitionEvent $event
* The workflow transition event.
* @param string $event_name
* The event name.
* @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
* The dispatcher.
*/
public function triggerPrePlace(WorkflowTransitionEvent $event, $event_name, EventDispatcherInterface $dispatcher): void {
$this->triggerPlaceEvent($event, $dispatcher, 'pre_transition');
}
/**
* Trigger the post place event when it has been skipped.
*
* @param \Drupal\state_machine\Event\WorkflowTransitionEvent $event
* The workflow transition event.
* @param string $event_name
* The event name.
* @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
* The dispatcher.
*/
public function triggerPostPlace(WorkflowTransitionEvent $event, $event_name, EventDispatcherInterface $dispatcher): void {
$this->triggerPlaceEvent($event, $dispatcher, 'post_transition');
}
/**
* Notify the customer when payment fails.
*
* @param \Drupal\state_machine\Event\WorkflowTransitionEvent $event
* The workflow transition event.
*/
public function notifyFailed(WorkflowTransitionEvent $event): void {
if (!($order = $this->validateOrder($event))) {
return;
}
try {
$this->sendEmail('paymentFailure', $order);
}
catch (\Exception $e) {
$this->logger->warning('Failed to notify order failure for order ' . $order->id());
}
}
/**
* Notify the customer when payment is successfully taken.
*
* @param \Drupal\state_machine\Event\WorkflowTransitionEvent $event
* The workflow transition event.
*/
public function notifyPaid(WorkflowTransitionEvent $event): void {
if (!($order = $this->validateOrder($event))) {
return;
}
try {
$this->sendEmail('paymentSuccess', $order);
}
catch (\Exception $e) {
$this->logger->warning('Failed to notify order payment success for order ' . $order->id());
}
}
/**
* Clears the renewal product field on the subscription.
*
* @param \Drupal\state_machine\Event\WorkflowTransitionEvent $event
* Event.
*/
public function clearRenewalProduct(WorkflowTransitionEvent $event) {
if (!($order = $this->validateOrder($event))) {
return;
}
if ($subscription = $this->subscriptionsHelper->getSubscriptionFromOrder($order)) {
$subscription
->set('renewal_product', NULL)
->save();
}
}
/**
* Get and validate the order from the event.
*
* @param \Drupal\state_machine\Event\WorkflowTransitionEvent $event
* The transition event.
*
* @return \Drupal\commerce_order\Entity\OrderInterface|null
* The order, if valid.
*/
protected function validateOrder(WorkflowTransitionEvent $event): ?OrderInterface {
$order = $event->getEntity();
return $order instanceof OrderInterface && $order->bundle() === 'contacts_subscription' ?
$order :
NULL;
}
/**
* Apply a subscription transition, if allowed.
*
* @param \Drupal\contacts_subscriptions\Entity\SubscriptionInterface $subscription
* The subscription.
* @param string $transition_id
* The transition ID to apply.
* @param bool $save
* Whether to save if the transition is applied.
*
* @return bool
* Whether the transition was applied.
*
* @throws \Drupal\Core\Entity\EntityStorageException
* @throws \Drupal\Core\TypedData\Exception\MissingDataException
*/
protected function applyTransitionIfAllowed(SubscriptionInterface $subscription, string $transition_id, bool $save = TRUE): bool {
/** @var \Drupal\state_machine\Plugin\Field\FieldType\StateItemInterface $state */
$state = $subscription->get('status')->first();
$workflow = $state->getWorkflow();
$transitions = $workflow->getAllowedTransitions($state->value, $subscription);
if (!isset($transitions[$transition_id])) {
return FALSE;
}
$subscription->setNewRevision();
$subscription->setRevisionCreationTime($this->time->getCurrentTime());
$subscription->setRevisionUserId($this->currentUser->id());
$state->applyTransition($transitions[$transition_id]);
if ($save) {
$subscription->save();
}
return TRUE;
}
/**
* Trigger the pre/post place event when it has been skipped.
*
* @param \Drupal\state_machine\Event\WorkflowTransitionEvent $event
* The workflow transition event.
* @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
* The dispatcher.
* @param string $phase
* The phase of the transition.
*/
protected function triggerPlaceEvent(WorkflowTransitionEvent $event, EventDispatcherInterface $dispatcher, string $phase): void {
if (!($this->validateOrder($event))) {
return;
}
// If this is not the place transition, but we have gone from draft to
// another post-placed state, trigger the post place event.
if ($event->getTransition()->getId() === 'place' || $event->getField()->getOriginalId() !== 'draft') {
return;
}
$group_id = $event->getField()->getWorkflow()->getGroup();
$dispatcher->dispatch(
$group_id . '.place.' . $phase,
$event
);
}
/**
* Send a notification email.
*
* @param string $key
* The email key.
* @param \Drupal\commerce_order\Entity\OrderInterface $order
* The order.
*/
protected function sendEmail(string $key, OrderInterface $order): void {
$customer = $order->getCustomer();
$to = "{$customer->getDisplayName()} <{$order->getEmail()}>";
$this->mailManager->mail(
'contacts_subscriptions',
$key,
$to,
$customer->getPreferredLangcode(),
['order' => $order],
);
}
/**
* Get the renewal date, falling back to a sensible default.
*
* @param \Drupal\commerce_order\Entity\OrderInterface $order
* The order entity.
* @param \Drupal\contacts_subscriptions\Entity\SubscriptionInterface|null $subscription
* The subscription entity.
*
* @return \Drupal\Core\Datetime\DrupalDateTime
* The renewal date.
*/
protected function getRenewal(OrderInterface $order, ?SubscriptionInterface $subscription): DrupalDateTime {
// Check for an explicit renewal.
$renewal = $order->getData('contacts_subscription_renewal');
if ($renewal) {
return new DrupalDateTime($renewal);
}
// Work out the default renewal date. If the subscription is active, start
// from there. Otherwise, use today's date.
$start_date = $subscription->isActive() ?
$subscription->getRenewalDate(FALSE) :
DrupalDateTime::createFromTimestamp($this->time->getRequestTime(), 'UTC');
// Add our default duration of 1 year.
return (clone $start_date)->add(new \DateInterval('P1Y'));
}
}
