diff --git a/commerce_license.module b/commerce_license.module index c48fb93..e70e68f 100644 --- a/commerce_license.module +++ b/commerce_license.module @@ -12,7 +12,6 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Session\AccountInterface; use Drupal\commerce_license\FormAlter\GrantedEntityFormAlter; -use Drupal\commerce_license\FormAlter\ProductVariationTypeFormAlter; /** * Implements hook_help(). @@ -72,9 +71,24 @@ function commerce_license_commerce_order_item_delete(EntityInterface $entity) { return; } - // Delete the license. $license = $entity->license->entity; - $license->delete(); + if ($license->state->value == 'renewal_in_progress') { + // Already active license was chosen to renew. + // Need to put it back in active state. + // But directing seting active state will be caught by License::preSave() ane extend its expiry time! + // So, firt put it into renewal_cancelled state, then to active. + $transition = $license->getState()->getWorkflow()->getTransition('cancel_renewal'); + $license->getState()->applyTransition($transition); + $license->save(); + + $transition = $license->getState()->getWorkflow()->getTransition('confirm'); + $license->getState()->applyTransition($transition); + $license->save(); + } + else { + // Delete the license. + $license->delete(); + } } /** diff --git a/commerce_license.services.yml b/commerce_license.services.yml index d301a15..09ac7e3 100644 --- a/commerce_license.services.yml +++ b/commerce_license.services.yml @@ -23,6 +23,15 @@ services: tags: - { name: commerce_order.order_processor } + commerce_license.license_renewal_cart_event_subscriber: + class: Drupal\commerce_license\EventSubscriber\LicenseRenewalCartEventSubscriber + arguments: + - '@entity_type.manager' + - '@messenger' + - '@date.formatter' + tags: + - { name: event_subscriber } + commerce_license.license_multiples_cart_event_subscriber: class: Drupal\commerce_license\EventSubscriber\LicenseMultiplesCartEventSubscriber tags: diff --git a/commerce_license.workflows.yml b/commerce_license.workflows.yml index 1e1ddf4..4a56eb1 100644 --- a/commerce_license.workflows.yml +++ b/commerce_license.workflows.yml @@ -11,6 +11,10 @@ license_default: label: Pending active: label: Active + renewal_in_progress: + label: Renewal in progress + renewal_cancelled: + label: Renewal cancelled suspended: label: Suspended expired: @@ -31,8 +35,16 @@ license_default: to: pending confirm: label: 'Confirm Activation' - from: [new, pending] + from: [new, pending, renewal_in_progress, renewal_cancelled] to: active + renewal_in_progress: + label: 'Renewal in progress' + from: [active] + to: renewal_in_progress + cancel_renewal: + label: 'Cancel renewal' + from: [renewal_in_progress] + to: renewal_cancelled suspend: label: 'Suspend' from: [active] diff --git a/config/schema/commerce_license.schema.yml b/config/schema/commerce_license.schema.yml index 2b112e7..e1d0446 100644 --- a/config/schema/commerce_license.schema.yml +++ b/config/schema/commerce_license.schema.yml @@ -8,6 +8,15 @@ commerce_product.commerce_product_variation_type.*.third_party.commerce_license: activate_on_place: type: boolean label: 'Whether to activate a license when the order is placed' + allow_renewal: + type: boolean + label: 'Allow renewal of license before expiration' + interval: + type: text + label: 'Allow renewal of license within this timeframe of expiration (multiplier)' + period: + type: text + label: 'Allow renewal of license within this timeframe of expiration (period unit)' views.field.commerce_license__entity_label: type: views.field.entity_label diff --git a/src/Entity/License.php b/src/Entity/License.php index c74e1af..f97aaca 100644 --- a/src/Entity/License.php +++ b/src/Entity/License.php @@ -2,6 +2,7 @@ namespace Drupal\commerce_license\Entity; +use Drupal\commerce_product\Entity\ProductVariationType; use Drupal\commerce\EntityOwnerTrait; use Drupal\commerce_license\Plugin\Commerce\LicenseType\LicenseTypeInterface; use Drupal\Core\Entity\EntityStorageInterface; @@ -9,6 +10,7 @@ use Drupal\Core\Field\BaseFieldDefinition; use Drupal\Core\Entity\ContentEntityBase; use Drupal\Core\Entity\EntityChangedTrait; use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; /** * Defines the License entity. @@ -67,6 +69,16 @@ class License extends ContentEntityBase implements LicenseInterface { use EntityChangedTrait; use EntityOwnerTrait; + use StringTranslationTrait; + + /** + * The renewal window start time. + * + * Calculated in the case of a renewable license. + * + * @var int|null + */ + protected $renewalWindowStartTime = NULL; /** * {@inheritdoc} @@ -113,6 +125,17 @@ class License extends ContentEntityBase implements LicenseInterface { $this->setRenewedTime($activation_time); } + // Renewal completed. + if (isset($this->original) && $this->original->state->value == 'renewal_in_progress') { + $expires_time = $this->getExpiresTime(); + if ($expires_time < $activation_time) { + $expires_time = $activation_time; + } + $this->setExpiresTime( + $this->calculateExpirationTime($expires_time) + ); + } + // Set the expiry time on a new license, but allow licenses to be // created with a set expiry, such as in the case of a migration. if (!$this->getExpiresTime()) { @@ -230,6 +253,13 @@ class License extends ContentEntityBase implements LicenseInterface { return $this; } + /** + * {@inheritdoc} + */ + public function getRenewalWindowStartTime() { + return $this->renewalWindowStartTime; + } + /** * Calculate the expiration time for this license from a start time. * @@ -240,6 +270,8 @@ class License extends ContentEntityBase implements LicenseInterface { * The expiry timestamp, or the value * \Drupal\recurring_period\Plugin\RecurringPeriod\RecurringPeriodInterface::UNLIMITED * if the license does not expire. + * + * @throws \Exception */ protected function calculateExpirationTime($start) { /** @var \Drupal\recurring_period\Plugin\RecurringPeriod\RecurringPeriodInterface $expiration_type_plugin */ @@ -435,4 +467,63 @@ class License extends ContentEntityBase implements LicenseInterface { return $fields; } + /** + * {@inheritdoc} + */ + public function canRenew() { + if (!in_array($this->state->value, ['active', 'renewal_in_progress'])) { + return FALSE; + } + + $variation = $this->getPurchasedEntity(); + $product_variation_type_id = $variation->bundle(); + $product_variation_type = ProductVariationType::load( + $product_variation_type_id + ); + + $allow_renewal = $product_variation_type->getThirdPartySetting( + 'commerce_license', + 'allow_renewal', + FALSE + ); + if (!$allow_renewal) { + return FALSE; + } + + $allow_renewal_window_interval = $product_variation_type->getThirdPartySetting( + 'commerce_license', + 'interval' + ); + $allow_renewal_window_period = $product_variation_type->getThirdPartySetting( + 'commerce_license', + 'period' + ); + + // Code from Drupal\recurring_period\Plugin\RecurringPeriod\RollingInterval + // method calculateDate. + // Create a DateInterval that represents the interval. + // TODO: This can be removed when https://www.drupal.org/node/2900435 lands. + $interval_plugin_definition = \Drupal::service( + 'plugin.manager.interval.intervals' + )->getDefinition($allow_renewal_window_period); + $value = $allow_renewal_window_interval * $interval_plugin_definition['multiplier']; + $date_interval = \DateInterval::createFromDateString( + $value . ' ' . $interval_plugin_definition['php'] + ); + $renewal_window_start_time = (new \DateTime( + date('r', $this->getExpiresTime()) + )) + ->setTimezone( + new \DateTimeZone(commerce_license_get_user_timezone($this->getOwner())) + ); + $renewal_window_start_time->sub($date_interval); + $this->renewalWindowStartTime = $renewal_window_start_time->getTimestamp(); + if ($this->renewalWindowStartTime < \Drupal::time()->getRequestTime()) { + return TRUE; + } + else { + return FALSE; + } + } + } diff --git a/src/Entity/LicenseInterface.php b/src/Entity/LicenseInterface.php index c8c615c..210f4e8 100644 --- a/src/Entity/LicenseInterface.php +++ b/src/Entity/LicenseInterface.php @@ -87,6 +87,16 @@ interface LicenseInterface extends EntityChangedInterface, EntityOwnerInterface */ public function setRenewedTime($timestamp); + /** + * The renewal window start time. + * + * Calculated in the case of a renewable license. + * + * @return int|null + * The renewal window start time. + */ + public function getRenewalWindowStartTime(); + /** * Get an unconfigured instance of the associated license type plugin. * @@ -152,4 +162,12 @@ interface LicenseInterface extends EntityChangedInterface, EntityOwnerInterface */ public static function getWorkflowId(LicenseInterface $license); + /** + * Checks if the license can be renewed at this time. + * + * @return bool + * TRUE if the license can be renewed. FALSE otherwise. + */ + public function canRenew(); + } diff --git a/src/EventSubscriber/LicenseMultiplesCartEventSubscriber.php b/src/EventSubscriber/LicenseMultiplesCartEventSubscriber.php index 7078919..659ac6c 100644 --- a/src/EventSubscriber/LicenseMultiplesCartEventSubscriber.php +++ b/src/EventSubscriber/LicenseMultiplesCartEventSubscriber.php @@ -45,6 +45,8 @@ class LicenseMultiplesCartEventSubscriber implements EventSubscriberInterface { * * @param \Drupal\commerce_cart\Event\CartEntityAddEvent $event * The cart event. + * + * @throws \Drupal\Core\Entity\EntityStorageException */ public function onCartEntityAdd(CartEntityAddEvent $event) { $order_item = $event->getOrderItem(); @@ -76,6 +78,8 @@ class LicenseMultiplesCartEventSubscriber implements EventSubscriberInterface { * * @param \Drupal\commerce_cart\Event\CartOrderItemUpdateEvent $event * The cart event. + * + * @throws \Drupal\Core\Entity\EntityStorageException */ public function onCartItemUpdate(CartOrderItemUpdateEvent $event) { $order_item = $event->getOrderItem(); diff --git a/src/EventSubscriber/LicenseRenewalCartEventSubscriber.php b/src/EventSubscriber/LicenseRenewalCartEventSubscriber.php new file mode 100644 index 0000000..1063326 --- /dev/null +++ b/src/EventSubscriber/LicenseRenewalCartEventSubscriber.php @@ -0,0 +1,147 @@ +licenseStorage = $entity_type_manager->getStorage('commerce_license'); + $this->entityTypeManager = $entity_type_manager; + $this->messenger = $messenger; + $this->dateFormatter = $date_formatter; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + + $events = [ + CartEvents::CART_ENTITY_ADD => ['onCartEntityAdd', 100], + ]; + return $events; + } + + /** + * Sets the already existing license in the order item. + * + * @param \Drupal\commerce_cart\Event\CartEntityAddEvent $event + * The cart event. + * + * @throws \Drupal\Core\Entity\EntityStorageException + * @throws \Drupal\Core\TypedData\Exception\MissingDataException + */ + public function onCartEntityAdd(CartEntityAddEvent $event) { + $order_item = $event->getOrderItem(); + // Only act if the order item has a license reference field. + if (!$order_item->hasField('license')) { + return; + } + // We can't renew license types that don't allow us to find a license + // given only a product variation and a user. + $variation = $order_item->getPurchasedEntity(); + + $license_type_plugin = $variation->get('license_type')->first()->getTargetInstance(); + if (!($license_type_plugin instanceof ExistingRightsFromConfigurationCheckingInterface)) { + return; + } + $existing_license = $this->licenseStorage->getExistingLicense($variation, $order_item->getOrder()->getCustomerId()); + if ($existing_license && $existing_license->canRenew()) { + $order_item->set('license', $existing_license->id()); + $order_item->save(); + + $transition = $existing_license->getState()->getWorkflow()->getTransition('renewal_in_progress'); + $existing_license->getState()->applyTransition($transition); + $existing_license->save(); + + // Shows a message with existing and extended dates when order completed. + $expiresTime = $existing_license->getExpiresTime(); + $datetime = (new \DateTimeImmutable())->setTimestamp($expiresTime); + $extendedDatetime = $existing_license->getExpirationPlugin()->calculateEnd($datetime); + + // TODO: link here once there is user admin UI for licenses! + \Drupal::messenger()->addStatus( + t('You have an existing license for @product-label until @expires-time. + This will be extended until @extended-date when you complete this order.', [ + "@product-label" => $existing_license->label(), + "@expires-time" => \Drupal::service('date.formatter')->format($expiresTime), + "@extended-date" => \Drupal::service('date.formatter')->format($extendedDatetime->getTimestamp()), + ]) + ); + } + elseif ($existing_license) { + + // This will never be fired when expected, + // since the CART_ENTITY_ADD is not fired at this point ? + $renewal_window_start_time = $existing_license->getRenewalWindowStartTime(); + + if (!is_null($renewal_window_start_time)) { + $this->messenger->addStatus($this->t('You have an existing license for this product. You will be able to renew your license after %date.', [ + '%date' => $this->dateFormatter->format($renewal_window_start_time), + ])); + } + } + } + +} diff --git a/src/FormAlter/ProductVariationTypeFormAlter.php b/src/FormAlter/ProductVariationTypeFormAlter.php index 47f6cb1..1f45763 100644 --- a/src/FormAlter/ProductVariationTypeFormAlter.php +++ b/src/FormAlter/ProductVariationTypeFormAlter.php @@ -80,6 +80,37 @@ class ProductVariationTypeFormAlter implements FormAlterInterface { '#default_value' => $product_variation_type->getThirdPartySetting('commerce_license', 'activate_on_place', FALSE), ]; + $our_form['license']['allow_renewal'] = [ + '#type' => 'checkbox', + '#title' => t("Allow renewal before expiration"), + '#description' => t( + "Allows a customer to renew their license by re-purchasing the product for it." + ), + '#default_value' => $product_variation_type->getThirdPartySetting('commerce_license', 'allow_renewal', FALSE), + ]; + + $our_form['license']['allow_renewal_window'] = [ + '#type' => 'details', + '#title' => t("Allow renewal window"), + '#open' => TRUE, + '#states' => [ + 'visible' => [ + 'input#edit-allow-renewal' => ['checked' => TRUE], + ], + ], + ]; + + $our_form['license']['allow_renewal_window']['interval'] = [ + '#type' => 'interval', + '#title' => t("Allow renewal window"), + '#description' => t( + "The interval before the license's expiry during which re-purchase is allowed. Prior to this interval, re-purchase is blocked, as normal." + ), + '#default_value' => [ + 'interval' => $product_variation_type->getThirdPartySetting('commerce_license', 'interval'), + 'period' => $product_variation_type->getThirdPartySetting('commerce_license', 'period'), + ], + ]; // Insert our form elements into the form after the 'traits' element. // The form elements don't have their weight set, so we can't use that. $traits_element_form_array_index = array_search('traits', array_keys($form)); @@ -102,6 +133,13 @@ class ProductVariationTypeFormAlter implements FormAlterInterface { * Form validation callback. * * Ensures that everything joins up when a license trait is used. + * + * @param $form + * @param \Drupal\Core\Form\FormStateInterface $form_state + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * @throws \Drupal\Core\Entity\EntityMalformedException */ public static function formValidate($form, FormStateInterface $form_state) { $traits = $form_state->getValue('traits'); @@ -162,6 +200,10 @@ class ProductVariationTypeFormAlter implements FormAlterInterface { $save_variation_type = TRUE; $variation_type->unsetThirdPartySetting('commerce_license', 'license_types'); $variation_type->unsetThirdPartySetting('commerce_license', 'activate_on_place'); + // Do the following 3 require unsetting too? + $variation_type->unsetThirdPartySetting('commerce_license', 'allow_renewal'); + $variation_type->unsetThirdPartySetting('commerce_license', 'interval'); + $variation_type->unsetThirdPartySetting('commerce_license', 'period'); } } else { @@ -172,6 +214,15 @@ class ProductVariationTypeFormAlter implements FormAlterInterface { $activate_on_place = $form_state->getValue('activate_on_place'); $variation_type->setThirdPartySetting('commerce_license', 'activate_on_place', $activate_on_place); + $allow_renewal = $form_state->getValue('allow_renewal'); + $variation_type->setThirdPartySetting('commerce_license', 'allow_renewal', $allow_renewal); + + $interval = $form_state->getValue('interval'); + $variation_type->setThirdPartySetting('commerce_license', 'interval', $interval); + + $period = $form_state->getValue('period'); + $variation_type->setThirdPartySetting('commerce_license', 'period', $period); + $order_item_type_id = $form_state->getValue('orderItemType'); /** @var \Drupal\commerce_order\Entity\OrderItemType $order_item_type */ diff --git a/src/LicenseAvailabilityCheckerExistingRights.php b/src/LicenseAvailabilityCheckerExistingRights.php index 5f7b6e2..8568cca 100644 --- a/src/LicenseAvailabilityCheckerExistingRights.php +++ b/src/LicenseAvailabilityCheckerExistingRights.php @@ -94,10 +94,76 @@ class LicenseAvailabilityCheckerExistingRights implements AvailabilityCheckerInt // grant. $customer = $context->getCustomer(); $purchased_entity = $order_item->getPurchasedEntity(); - $license_type_plugin = $purchased_entity->license_type->first()->getTargetInstance(); // Load the full user entity for the plugin. $user = $this->entityTypeManager->getStorage('user')->load($customer->id()); + + // Handle licence renewal. + /** @var \Drupal\commerce_license\Entity\LicenseInterface $existing_license */ + $existing_license = $this->entityTypeManager + ->getStorage('commerce_license') + ->getExistingLicense($purchased_entity, $user->id()); + + if ($existing_license && $existing_license->canRenew()) { + return; + } + + // Shows a message to indicate window start time, + // in case license is renewable but we're out of its renewable window. + $unsetNotPurchasableMessage = FALSE; + if ($existing_license && !is_null($existing_license->getRenewalWindowStartTime())) { + $this->setRenewalStartTimeMessage( + $existing_license->getRenewalWindowStartTime(), + $purchased_entity->label() + ); + + // Removes the notPurchasable message. + $unsetNotPurchasableMessage = TRUE; + // TODO remove Drupal\commerce_order\Plugin\Validation\Constraint message: + // @product-label is not available with a quantity of @quantity. + } + + return $this->checkPurchasable($purchased_entity, $user, $unsetNotPurchasableMessage); + } + + /** + * Adds a renewalStartTimeMessage status message to queue. + * + * @param int|null $renewal_window_start_time + * The renewal window start time. + * @param string $label + * The purchased product label. + */ + private function setRenewalStartTimeMessage($renewal_window_start_time, $label) { + \Drupal::messenger()->addStatus( + t('You have an existing license for this @product-label. You will be able to renew your license after @date.', [ + '@date' => \Drupal::service('date.formatter')->format($renewal_window_start_time), + '@product-label' => $label, + ]) + ); + } + + /** + * Checks if new license is eligible for purchase. + * + * Hand over to the license type plugin configured in the product variation, + * to let it determine whether the user already has what the license would + * grant. Adds a notPurchasableMessage status message to queue. + * + * @param PurchasableEntityInterface $entity + * The purchased entity. + * @param \Drupal\Core\Entity\EntityInterface $user + * The user the license would be granted to. + * @param bool $unsetNotPurchasableMessage + * Whether to display a notPurchasableMessage message or not. + * + * @return mixed + * The availability of an order item. + * + * @throws \Drupal\Core\TypedData\Exception\MissingDataException + */ + private function checkPurchasable($entity, $user, $unsetNotPurchasableMessage) { + $license_type_plugin = $entity->get('license_type')->first()->getTargetInstance(); $existing_rights_result = $license_type_plugin->checkUserHasExistingRights($user); if (!$existing_rights_result->hasExistingRights()) { @@ -112,7 +178,7 @@ class LicenseAvailabilityCheckerExistingRights implements AvailabilityCheckerInt $rights_check_message = $existing_rights_result->getOtherUserMessage(); } $message = $rights_check_message . ' ' . t("You may not purchase the @product-label product.", [ - '@product-label' => $purchased_entity->label(), + '@product-label' => $entity->label(), ]); return AvailabilityResult::unavailable($message); diff --git a/src/LicenseOrderProcessorMultiples.php b/src/LicenseOrderProcessorMultiples.php index 76f58f9..0f683d9 100644 --- a/src/LicenseOrderProcessorMultiples.php +++ b/src/LicenseOrderProcessorMultiples.php @@ -24,21 +24,34 @@ class LicenseOrderProcessorMultiples implements OrderProcessorInterface { * {@inheritdoc} */ public function process(OrderInterface $order) { + $license_items = 0; foreach ($order->getItems() as $order_item) { // Skip order items that do not have a license reference field. if (!$order_item->hasField('license')) { continue; } + $license_items++; // TODO: Allow license type plugins to respond here, as for types that // collect user data in the checkout form, the same product variation can // result in different licenses. $quantity = $order_item->getQuantity(); - if ($quantity > 1) { + if ($quantity > 1 || $license_items > 1) { + $purchased_entity = $order_item->getPurchasedEntity(); + if ($license_items > 1) { + $message = t("You already have one item similar to @product-label in your cart.", [ + '@product-label' => $purchased_entity->label(), + ]); + $order->removeItem($order_item); + } + else { + $message = t("You may only have one of @product-label in your cart.", [ + '@product-label' => $purchased_entity->label(), + ]); + } // Force the quantity back to 1. $order_item->setQuantity(1); - $purchased_entity = $order_item->getPurchasedEntity(); if ($purchased_entity) { // Note that this message shows both when attempting to increase the // quantity of a license product already in the cart, and when @@ -46,9 +59,7 @@ class LicenseOrderProcessorMultiples implements OrderProcessorInterface { // In the latter case, the message isn't as clear as it could be, but // site builders should be hiding the quantity field from the add to // cart form for license products, so this is moot. - \Drupal::messenger()->addError(t("You may only have one of @product-label in your cart.", [ - '@product-label' => $purchased_entity->label(), - ])); + \Drupal::messenger()->addError(t($message)); } } } diff --git a/src/LicenseStorage.php b/src/LicenseStorage.php index 597cd23..a0356eb 100644 --- a/src/LicenseStorage.php +++ b/src/LicenseStorage.php @@ -55,4 +55,23 @@ class LicenseStorage extends CommerceContentEntityStorage implements LicenseStor return $license; } + /** + * {@inheritdoc} + */ + public function getExistingLicense(ProductVariationInterface $variation, $uid) { + $existing_licenses_ids = $this->getQuery() + ->condition('state', ['active', 'renewal_in_progress'], 'IN') + ->condition('uid', $uid) + ->condition('product_variation', $variation->id()) + ->execute(); + + if (!empty($existing_licenses_ids)) { + $existing_license_id = array_shift($existing_licenses_ids); + return $this->load($existing_license_id); + } + else { + return FALSE; + } + } + } diff --git a/src/LicenseStorageInterface.php b/src/LicenseStorageInterface.php index 463f3b3..75ceb2f 100644 --- a/src/LicenseStorageInterface.php +++ b/src/LicenseStorageInterface.php @@ -16,6 +16,19 @@ use Drupal\commerce_product\Entity\ProductVariationInterface; */ interface LicenseStorageInterface extends ContentEntityStorageInterface { + /** + * Get existing active license given a product variation and a user ID. + * + * @param \Drupal\commerce_product\Entity\ProductVariationInterface $variation + * The product variation. + * @param int $uid + * The uid for whom the license will be retrieved. + * + * @return \Drupal\commerce_license\Entity\LicenseInterface|false + * An existing license entity. FALSE otherwise. + */ + public function getExistingLicense(ProductVariationInterface $variation, $uid); + /** * Creates a new license from an order item. * diff --git a/src/Plugin/AdvancedQueue/JobType/LicenseExpire.php b/src/Plugin/AdvancedQueue/JobType/LicenseExpire.php index 785989e..7c9bebe 100644 --- a/src/Plugin/AdvancedQueue/JobType/LicenseExpire.php +++ b/src/Plugin/AdvancedQueue/JobType/LicenseExpire.php @@ -9,6 +9,7 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Exception; use Symfony\Component\DependencyInjection\ContainerInterface; +use Drupal\Component\Datetime\TimeInterface; /** * Provides the job type for expiring licenses. @@ -27,6 +28,13 @@ class LicenseExpire extends JobTypeBase implements ContainerFactoryPluginInterfa */ protected $entityTypeManager; + /** + * The time. + * + * @var \Drupal\Component\Datetime\TimeInterface + */ + protected $time; + /** * Constructs a new LicenseExpire object. * @@ -38,11 +46,14 @@ class LicenseExpire extends JobTypeBase implements ContainerFactoryPluginInterfa * The plugin implementation definition. * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, TimeInterface $time) { parent::__construct($configuration, $plugin_id, $plugin_definition); $this->entityTypeManager = $entity_type_manager; + $this->time = $time; } /** @@ -53,7 +64,8 @@ class LicenseExpire extends JobTypeBase implements ContainerFactoryPluginInterfa $configuration, $plugin_id, $plugin_definition, - $container->get('entity_type.manager') + $container->get('entity_type.manager'), + $container->get('datetime.time') ); } @@ -73,6 +85,10 @@ class LicenseExpire extends JobTypeBase implements ContainerFactoryPluginInterfa return JobResult::failure('License is no longer active.'); } + if ($license->getExpiresTime() > $this->time->getRequestTime()) { + return JobResult::failure('License is not expired.'); + } + try { // Set the license to expired. The plugin will take care of revoking it. $license->state = 'expired'; diff --git a/tests/modules/commerce_license_test/src/Plugin/Commerce/LicenseType/RenewableLicenseType.php b/tests/modules/commerce_license_test/src/Plugin/Commerce/LicenseType/RenewableLicenseType.php new file mode 100644 index 0000000..7a0608b --- /dev/null +++ b/tests/modules/commerce_license_test/src/Plugin/Commerce/LicenseType/RenewableLicenseType.php @@ -0,0 +1,30 @@ +t("You already have the rights."), + $this->t("The user already has the rights.") + ); + } + +} diff --git a/tests/src/Kernel/CommerceOrderSyncRenewalTest.php b/tests/src/Kernel/CommerceOrderSyncRenewalTest.php new file mode 100644 index 0000000..6a467dc --- /dev/null +++ b/tests/src/Kernel/CommerceOrderSyncRenewalTest.php @@ -0,0 +1,445 @@ +installEntitySchema('profile'); + $this->installEntitySchema('commerce_product'); + $this->installEntitySchema('commerce_product_variation'); + $this->installEntitySchema('commerce_order'); + $this->installEntitySchema('commerce_order_item'); + $this->installEntitySchema('commerce_license'); + $this->installConfig('system'); + $this->installConfig('commerce_order'); + $this->installConfig('commerce_product'); + $this->createUser(); + + $this->licenseStorage = $this->container->get('entity_type.manager')->getStorage('commerce_license'); + + // Create an order type for licenses which uses the fulfillment workflow. + $this->orderType = $this->createEntity('commerce_order_type', [ + 'id' => 'license_order_type', + 'label' => $this->randomMachineName(), + 'workflow' => 'order_default', + ]); + commerce_order_add_order_items_field($this->orderType); + + // Create an order item type that uses that order type. + $order_item_type = $this->createEntity('commerce_order_item_type', [ + 'id' => 'license_order_item_type', + 'label' => $this->randomMachineName(), + 'purchasableEntityType' => 'commerce_product_variation', + 'orderType' => 'license_order_type', + 'traits' => ['commerce_license_order_item_type'], + ]); + $this->traitManager = \Drupal::service('plugin.manager.commerce_entity_trait'); + $trait = $this->traitManager->createInstance('commerce_license_order_item_type'); + $this->traitManager->installTrait($trait, 'commerce_order_item', $order_item_type->id()); + + // Create a product variation type with the license trait, using our order + // item type. + $this->variationType = $this->createEntity('commerce_product_variation_type', [ + 'id' => 'license_pv_type', + 'label' => $this->randomMachineName(), + 'orderItemType' => 'license_order_item_type', + 'traits' => ['commerce_license'], + ]); + $trait = $this->traitManager->createInstance('commerce_license'); + $this->traitManager->installTrait($trait, 'commerce_product_variation', $this->variationType->id()); + + $this->variationType->setThirdPartySetting('commerce_license', 'allow_renewal', TRUE); + $this->variationType->setThirdPartySetting('commerce_license', 'interval', '1'); + $this->variationType->setThirdPartySetting('commerce_license', 'period', 'month'); + $this->variationType->setThirdPartySetting('commerce_license', 'activate_on_place', TRUE); + $this->variationType->save(); + + // Create a product variation which grants a license. + $this->variation = $this->createEntity('commerce_product_variation', [ + 'type' => 'license_pv_type', + 'sku' => $this->randomMachineName(), + 'price' => [ + 'number' => 999, + 'currency_code' => 'USD', + ], + 'license_type' => [ + 'target_plugin_id' => 'renewable', + 'target_plugin_configuration' => [], + ], + // Use the rolling interval expiry plugin as it's simple. + 'license_expiration' => [ + 'target_plugin_id' => 'rolling_interval', + 'target_plugin_configuration' => [ + 'interval' => [ + 'interval' => '1', + 'period' => 'year', + ], + ], + ], + ]); + + // We need a product too otherwise tests complain about the missing + // backreference. + $this->createEntity('commerce_product', [ + 'type' => 'default', + 'title' => $this->randomMachineName(), + 'stores' => [$this->store], + 'variations' => [$this->variation], + ]); + $this->reloadEntity($this->variation); + $this->variation->save(); + + // Create a product variation with a non-renewable license. + $this->nonRenewableVariationType = $this->createEntity('commerce_product_variation_type', [ + 'id' => 'license_nrpv_type', + 'label' => $this->randomMachineName(), + 'orderItemType' => 'license_order_item_type', + 'traits' => ['commerce_license'], + ]); + $this->traitManager->installTrait($trait, 'commerce_product_variation', $this->nonRenewableVariationType->id()); + + $this->nonRenewableVariationType->setThirdPartySetting('commerce_license', 'allow_renewal', FALSE); + $this->nonRenewableVariationType->save(); + + // Create a product variation which grants a license. + $this->nonRenewableVariation = $this->createEntity('commerce_product_variation', [ + 'type' => 'license_nrpv_type', + 'sku' => $this->randomMachineName(), + 'price' => [ + 'number' => 999, + 'currency_code' => 'USD', + ], + 'license_type' => [ + 'target_plugin_id' => 'renewable', + 'target_plugin_configuration' => [], + ], + // Use the rolling interval expiry plugin as it's simple. + 'license_expiration' => [ + 'target_plugin_id' => 'rolling_interval', + 'target_plugin_configuration' => [ + 'interval' => [ + 'interval' => '1', + 'period' => 'year', + ], + ], + ], + ]); + + // We need a product too otherwise tests complain about the missing + // backreference. + $this->createEntity('commerce_product', [ + 'type' => 'default', + 'title' => $this->randomMachineName(), + 'stores' => [$this->store], + 'variations' => [$this->variation], + ]); + $this->reloadEntity($this->variation); + $this->variation->save(); + + // Create a user to use for orders. + $this->user = $this->createUser(); + + $this->installCommerceCart(); + $this->store = $this->createStore(); + + // Create a license in the active state. + $this->license = $this->createEntity('commerce_license', [ + 'type' => 'renewable', + 'state' => 'active', + 'product_variation' => $this->variation->id(), + 'uid' => $this->user->id(), + // 06/01/2015 @ 1:00pm (UTC). + 'expires' => '1433163600', + 'expiration_type' => [ + 'target_plugin_id' => 'rolling_interval', + 'target_plugin_configuration' => [ + 'interval' => [ + 'interval' => '1', + 'period' => 'year', + ], + ], + ], + ]); + + // Create a non renewable license in the active state. + $this->nonRenewableLicense = $this->createEntity('commerce_license', [ + 'type' => 'renewable', + 'state' => 'active', + 'product_variation' => $this->nonRenewableVariation->id(), + 'uid' => $this->user->id(), + // 06/01/2015 @ 1:00pm (UTC). + 'expires' => '1433163600', + 'expiration_type' => [ + 'target_plugin_id' => 'rolling_interval', + 'target_plugin_configuration' => [ + 'interval' => [ + 'interval' => '1', + 'period' => 'year', + ], + ], + ], + ]); + } + + /** + * Tests that a license can't be purchased outside the renewable window. + */ + public function testRenewOutsideRenewalWindow() { + // Mock the current time service. + $expiration_time_outside_window = strtotime('- 2 months', $this->license->getExpiresTime()); + + $mock_builder = $this->getMockBuilder('Drupal\Component\Datetime\TimeInterface') + ->disableOriginalConstructor(); + + $datetime_service = $mock_builder->getMock(); + $datetime_service->expects($this->atLeastOnce()) + ->method('getRequestTime') + ->willReturn($expiration_time_outside_window); + $this->container->set('datetime.time', $datetime_service); + + // Add a product with license to the cart. + $cart_order = $this->container->get('commerce_cart.cart_provider')->createCart('license_order_type', $this->store, $this->user); + $this->cartManager = $this->container->get('commerce_cart.cart_manager'); + $order_item = $this->cartManager->addEntity($cart_order, $this->variation); + + // Assert the order item is NOT in the cart. + $this->assertFalse($cart_order->hasItem($order_item)); + } + + /** + * Tests that a license is extended when you repurchased it. + */ + public function testRenewInRenewalWindow() { + // Mock the current time service. + $expiration_time_inside_window = strtotime('- 1 week', $this->license->getExpiresTime()); + + $mock_builder = $this->getMockBuilder('Drupal\Component\Datetime\TimeInterface') + ->disableOriginalConstructor(); + + $datetime_service = $mock_builder->getMock(); + $datetime_service->expects($this->atLeastOnce()) + ->method('getRequestTime') + ->willReturn($expiration_time_inside_window); + $this->container->set('datetime.time', $datetime_service); + + // Add a product with license to the cart. + $cart_order = $this->container->get('commerce_cart.cart_provider')->createCart('license_order_type', $this->store, $this->user); + $this->cartManager = $this->container->get('commerce_cart.cart_manager'); + $order_item = $this->cartManager->addEntity($cart_order, $this->variation); + $order_item = $this->reloadEntity($order_item); + + // Check that the order item has the previous license. + $this->assertNotNull($order_item->license->entity, 'The order item has a license set on it.'); + $this->assertEquals($this->license->id(), $order_item->license->entity->id(), 'The order item has a reference to the existing license.'); + + // Assert the order item IS IN the cart. + $this->assertTrue($cart_order->hasItem($order_item), 'The order item IS IN the cart.'); + + // Take the order through checkout. + $this->completeLicenseOrderCheckout($cart_order); + + // Reload the entity because it has been changed. + $this->license = $this->reloadEntity($this->license); + + $this->assertEquals(date(DATE_ATOM, strtotime('+1 year', 1433163600)), date(DATE_ATOM, $this->license->getExpiresTime()), 'The license has been extended for a year.'); + } + + /** + * Tests that a license is active after removing renewing product from cart. + */ + public function testRemovingProductFromCart() { + $initial_expiration_time = $this->license->getExpiresTime(); + + // Mock the current time service. + $expiration_time_inside_window = strtotime('- 1 week', $initial_expiration_time); + + $mock_builder = $this->getMockBuilder('Drupal\Component\Datetime\TimeInterface') + ->disableOriginalConstructor(); + + $datetime_service = $mock_builder->getMock(); + $datetime_service->expects($this->atLeastOnce()) + ->method('getRequestTime') + ->willReturn($expiration_time_inside_window); + $this->container->set('datetime.time', $datetime_service); + + // Add a product with license to the cart. + $cart_order = $this->container->get('commerce_cart.cart_provider')->createCart('license_order_type', $this->store, $this->user); + $this->cartManager = $this->container->get('commerce_cart.cart_manager'); + $order_item = $this->cartManager->addEntity($cart_order, $this->variation); + $order_item = $this->reloadEntity($order_item); + + // Check that the order item has the previous license. + $this->assertNotNull($order_item->license->entity, 'The order item has a license set on it.'); + $this->assertEquals($this->license->id(), $order_item->license->entity->id(), 'The order item has a reference to the existing license.'); + + // Assert the order item IS IN the cart. + $this->assertTrue($cart_order->hasItem($order_item), 'The order item IS IN the cart.'); + + // Test that the license is in renewal_in_progress state. + $this->assertEquals('renewal_in_progress', $this->license->getState()->value, 'The license is in "renewal in progress" state.'); + + // Remove the item from the cart. + $this->cartManager->removeOrderItem($cart_order, $order_item); + + // Assert the order item is NOT in the cart. + $this->assertFALSE($cart_order->hasItem($order_item), 'The order item is NOT in the cart.'); + + // Reload the entity because it may have been changed. + $this->license = $this->reloadEntity($this->license); + + // Test that the license is back to active state without the expiration date + // extended. + $this->assertEquals('active', $this->license->getState()->value, 'The license is back to the "active" state.'); + $this->assertEquals($initial_expiration_time, $this->license->getExpiresTime(), 'The license has still the same expiration time.'); + } + + /** + * Tests that a non renewable license can't be purchased if still active. + */ + public function testNonRenewableLicense() { + // Add a product with license to the cart. + $cart_order = $this->container->get('commerce_cart.cart_provider')->createCart('license_order_type', $this->store, $this->user); + $this->cartManager = $this->container->get('commerce_cart.cart_manager'); + $order_item = $this->cartManager->addEntity($cart_order, $this->nonRenewableVariation); + + // Assert the order item is NOT in the cart. + $this->assertFalse($cart_order->hasItem($order_item)); + } + + /** + * Creates and saves a new entity. + * + * @param string $entity_type + * The entity type to be created. + * @param array $values + * An array of settings. + * Example: 'id' => 'foo'. + * + * @return \Drupal\Core\Entity\EntityInterface + * A new, saved entity. + */ + protected function createEntity($entity_type, array $values) { + /** @var \Drupal\Core\Entity\EntityStorageInterface $storage */ + $storage = \Drupal::service('entity_type.manager')->getStorage($entity_type); + $entity = $storage->create($values); + $status = $entity->save(); + $this->assertEquals(SAVED_NEW, $status, new FormattableMarkup('Created %label entity %type.', [ + '%label' => $entity->getEntityType()->getLabel(), + '%type' => $entity->id(), + ])); + // The newly saved entity isn't identical to a loaded one, and would fail + // comparisons. + $entity = $storage->load($entity->id()); + + return $entity; + } + +} diff --git a/tests/src/Kernel/LicenseOrderCompletionTestTrait.php b/tests/src/Kernel/LicenseOrderCompletionTestTrait.php new file mode 100644 index 0000000..09890cc --- /dev/null +++ b/tests/src/Kernel/LicenseOrderCompletionTestTrait.php @@ -0,0 +1,40 @@ +getState()->getWorkflow(); + + // In all cases, place the order. + $cart_order->getState()->applyTransition($workflow->getTransition('place')); + $cart_order->save(); + + // The order is now either in state: + // - 'complete', if its workflow is 'order_default' + // - 'fulfillment', if its workflow is 'order_fulfillment' + + // Fulfil the order if it has that transtion. + $fulfil_transition = $workflow->getTransition('fulfill'); + if ($fulfil_transition) { + $cart_order->getState()->applyTransition($fulfil_transition); + $cart_order->save(); + } + } + +}