Problem/Motivation

The AvailabilityManager performs checks to see if a purchasable entity is available. Right now the availability of a purchasable entity is a simple boolean value. TRUE it is available and FALSE it is not available. Both the AvailabilityManagerInterface and AvailabilityCheckerInterface operate this way. Individual AvailabilityCheckerInterface implementations may return NULL if there is no opinion on a purchasable entity's availability.

In #3088597: Add a constraint to purchased_entity which checks the availability manager we discovered it would be much more useful to be able to return a specific error message about why a purchasable entity is not available.

For example, that is how the AccessResult value object works - they are allowed, neutral, or forbidden and optionally have a message.

We also must account for backward compatibility. Anyone using the availability manager will expect truthy values. Returning a value object will now cause logic checks only implement NOT checks to always be false, leading to false positives on availability.

Proposed resolution

Backwards compatibility

We’re not using strict types and return types, so we’re super flexible here! We can update \Drupal\commerce\AvailabilityCheckerInterface::check to allow returning AvailabilityManagerResult|bool|null.

The current check method on the availability manager must convert AvailabilityResult objects into a boolean for return.

We then add a new method that allows leveraging the AvailabilityResult object. For lack of better ideas, let’s call it checkWithReason for now. Because it can return a reason ¯\_(ツ)_/¯.

This will always return an AvailabilityManagerResult. If a checker returns a boolean value, we convert it.

This allows a progressive enhancement without breaking backward compatibility. We can also add some kind of note in both methods whenever we hit a boolean:

get_class($checker) . ' returned a boolean which is deprecated in Commerce 2.x, availability checkers will must return \Drupal\commerce\AvailableResultNeutral or \Drupal\commerce\AvailableResultForbidden in 3.x'

Value object

Create an AvailabilityResult value object to represent the result of the availability check. It should return if the purchasable entity is available and reasonings. Any additional properties can be bikeshed and added later (such as available onhand, etc.)

We can have AvailabilityResultUnavailable, and AvailabilityResultNeutral objects. Unline the access result objects, we will consider neutral to be available. Or we just allow AvailabilityCheckerInterface to return NULL as neutral and then AvailabilityManagerInterface returns an available value object if neutral. Most AvailabilityCheckerInterface would return NULL unless explicitly denying availability.

There is no reason for an "available" class, as we won't halt processing checkers if there is an allowed availability.

We can review the value response as proposed in a previous pull request: https://github.com/drupalcommerce/commerce/blob/329fabe2da6bab6cee33a903...

Remaining tasks

  • Decide on the approach to backward compatbility
  • Write and communicate a change record for the backward compatibility break
  • Commerce Stock is in alpha, but we would need to coordinate these breaking changes.
  • Decide if we should have a value object representing "null/neutral" or just boolean available/unavailable
  • Bikeshed the name of the value object and its properties

User interface changes

N/A

API changes

\Drupal\commerce\AvailabilityCheckerInterface::check may return a boolean value or an AvailabilityResult

\Drupal\commerce\AvailabilityManagerInterface::check will remain the same. It will convert AvailabilityResults to boolean, if returned.

\Drupal\commerce\AvailabilityManagerInterface now has checkWithReason that returns an AvailabilityResult

Data model changes

N/A

Release notes snippet

Comments

mglaman created an issue. See original summary.

mglaman’s picture

Assigned: Unassigned » mglaman
Issue summary: View changes

This issue summary is a mess. Cleaning up.

mglaman’s picture

Issue summary: View changes
mglaman’s picture

Issue summary: View changes
mglaman’s picture

Issue summary: View changes
mglaman’s picture

Title: Availability checks should use a value object instead of booleans » Availability checks should use a value object instead of boolean return values
Assigned: mglaman » Unassigned
olafkarsten’s picture

Dejavu: #2710107: Finalize the availability manager service for more background.

The value object from steveoliver is a good start. As discussed in the comments of his PR, we can omit the min qty and get away with one qty value (see here).

For commerce stock it would be fine, if we could return an own message plus the qty that is available. AvailabilityResult is a good name.
I will reach out for guy schneerson to join the discussion.

mglaman’s picture

@olafkarsten :) yeah, I'm going to take time to triage that issue. Especially now that we have #3088597: Add a constraint to purchased_entity which checks the availability manager.

olafkarsten’s picture

@mglaman The constraint idea is a really beautiful one. Thanks for getting that done. :)

It makes sense to triage the old issue. The comments on github and the issue are a good source. I think it was a comprehensive discussion back then, that covered a lot of use cases, not only stock. IIRC at that time commerce was in beta, much to do for bojanz and nobody wanted to break BC for commerce stock ... ;)

guy_schneerson’s picture

Hi @mglaman
I did like the idea of the min/max but agree with @olafkarsten that returning an absolute quantity value is good enough for stock.
As far the add to cart functionality no need to worry about stock backward compatibility as it currently does not use the availability manager. As long as the availability manager will support an "allowed quantity" and providing "a reason why not available" message, I think we should be able to easily move over to using it.
One area I don't remember much about is if/how the availability manager behaves on cart and checkout as those have other required behaviors. On the cart page, for example, we may not wish to automatically adjust the quantity, but I suspect this may be using existing stock validation and not down for the availability manager to handle.
Also backing @olafkarsten on the point of issues/2710107 and commerce/pull/593 containing useful information.

ransomweaver’s picture

I agree with @guy_schneerson about just requiring an allowed quantity. By way of example, I have a commerce_stock_backorderable module that overrides the commerce_stock availabilityChecker as a service. Here is the check function:

public function check(PurchasableEntityInterface $entity, $quantity, Context $context) {

    if ($stock_checker->getIsAlwaysInStock($entity)) {
      return AvailabilityResponse::available(0, $quantity);
    }

    $no_stock_sale_field = $entity->get('allow_no_stock_sale');
    if ($no_stock_sale_field) {
      $allow_no_stock = $no_stock_sale_field->value;
      if (boolval($allow_no_stock)) {
        return AvailabilityResponse::available(0, $quantity);
      }
    }

    $stock_service = $this->stockServiceManager->getService($entity);
    $stock_checker = $stock_service->getStockChecker();
    $stock_config = $stock_service->getConfiguration();
    $locations = $stock_config->getAvailabilityLocations($this->stockServiceManager->getContext($entity), $entity);
    $stock_level = $stock_checker->getTotalStockLevel($entity, $locations);

    if ($stock_level >= $quantity) {
      return AvailabilityResponse::available(0, $stock_level);
    }
    else {
      return AvailabilityResponse::unavailable(0, $stock_level, 'maximum exceeded');
    }
  }

}

As you can see for the available min I am just using 0, so I have no problem with changing min/max to just an allowed quantity.

mglaman’s picture

Assigned: Unassigned » mglaman

We recently went through this internally. I'll post an updated issue summary.

We're going to avoid the backward compatibility break identified here:

- public function check(PurchasableEntityInterface $entity, $quantity, Context $context);
+ public function check(PurchasableEntityInterface $entity, $quantity, Context $context, $return_as_object = FALSE);

check will always return a boolean. We'll provide a new method that returns the result object. Folks can then opt-in and use the new method to receive the result object and be able to return why something isn't available.

mglaman’s picture

Issue summary: View changes
mglaman’s picture

Issue summary: View changes
mglaman’s picture

Status: Active » Needs review
Issue tags: +Needs tests
StatusFileSize
new4.97 KB

Here is a first patch. I didn't expand any tests, yet, or add support for returning the value object.

mglaman’s picture

Issue summary: View changes
mglaman’s picture

Reading #11 and #12 again, I wonder about the available property. It seems like the available quantity is only required when a purchasable entity is not available. It's not something that is always needed.

If we did make it a value returned on the availability result, we would have to merge all returned values from availability checkers.

So it seems like on top of AvailableResultForbidden::$reason it would be beneficial to have AvailableResultForbidden::$available so that the consumer of that result could have more context.

Instead of just a reason of "Sorry, there are only 10." It could also provide the available count. This is more useful in a decoupled context as well, so the frontend doesn't need to parse the reason to find the number allowed.

For displaying allowed stock, that should be a more explicit call that doesn't rely on availability checks.

mglaman’s picture

StatusFileSize
new4.94 KB
new2.28 KB

Fix @trigger_error to use E_USER_DEPRECATED constant and lint problems. Forgot we require 7.0 minimum, not 7.1.

Status: Needs review » Needs work

The last submitted patch, 19: availability_checks-3107547-19.patch, failed testing. View results

mglaman’s picture

Status: Needs work » Needs review
StatusFileSize
new10.59 KB
new6.64 KB

This adds the new method and a base test.

mglaman’s picture

mglaman’s picture

Assigned: mglaman » Unassigned
Issue tags: -Needs tests
  1. +++ b/src/AvailabilityCheckerInterface.php
    @@ -29,9 +29,9 @@ interface AvailabilityCheckerInterface {
    +   * @return \Drupal\commerce\AvailableResult|bool|null
    +   *   An availability result if supported. Or TRUE if the entity is available,
    +   *   FALSE if it's unavailable, or NULL if it has no opinion.
    

    Needs better description. The interface needs to explain it is preferred to return an AvailableResult but we also allow bool|null.

  2. +++ b/src/AvailabilityManager.php
    @@ -35,7 +35,13 @@ class AvailabilityManager implements AvailabilityManagerInterface {
    +          @trigger_error(get_class($checker) . '::check returned a boolean which is deprecated in Commerce 2.x, availability checkers will must return \Drupal\commerce\AvailableResultNeutral or \Drupal\commerce\AvailableResultForbidden in 3.x', E_USER_DEPRECATED);
    
    @@ -44,4 +50,25 @@ class AvailabilityManager implements AvailabilityManagerInterface {
    +          @trigger_error(get_class($checker) . '::check returned a boolean which is deprecated in Commerce 2.x, availability checkers will must return \Drupal\commerce\AvailableResultNeutral or \Drupal\commerce\AvailableResultForbidden in 3.x', E_USER_DEPRECATED);
    

    This needs to get cleaned up to match the proper deprecated messaging. We also need a CR to link to.

  3. +++ b/src/AvailabilityManagerInterface.php
    @@ -45,4 +45,19 @@ interface AvailabilityManagerInterface {
    +  public function checkWithReason(PurchasableEntityInterface $entity, $quantity, Context $context);
    

    Needs a better name.

    checkWithResult
    checkResult

  4. +++ b/tests/modules/commerce_test/src/TestAvailabilityChecker.php
    @@ -3,6 +3,7 @@
    @@ -24,7 +25,7 @@ class TestAvailabilityChecker implements AvailabilityCheckerInterface {
    
    @@ -24,7 +25,7 @@ class TestAvailabilityChecker implements AvailabilityCheckerInterface {
        */
       public function check(PurchasableEntityInterface $entity, $quantity, Context $context) {
         // Always return false.
    -    return FALSE;
    +    return AvailableResult::forbidden('Test SKUs cannot be purchased.');
    

    I couldn't find where this service is actually used and tested. Which means we need a new test, I think.

ransomweaver’s picture

Regarding #18, the crux of the issue is in AvailabilityOrderProcessor::process

In this function we need to e.g. take a quantity of three for an item and if availabilityManager->check says there is only one available, we need to remove two from the order and tell the customer about it. So this product is "available", just not i the desired quantity. I guess this could be an example of "forbidden" where there is one available, and we do something about that here in ::process

If we look at its current state, https://git.drupalcode.org/project/commerce/-/blob/8.x-2.x/modules/order...

I don't get the feeling that it can do anything like that, which must be why I'm using PR 593

https://github.com/drupalcommerce/commerce/blob/329fabe2da6bab6cee33a903...

So yea, where PR 593 says $availability->getMax() < $order_item->getQuantity() and it triggers a change in quantity, we could instead be responding to AvailableResultForbidden and using AvailableResultForbidden::$available to change the quantity appropriately.

And if its not forbidden, yes, we don't care what the quantity is -- just return from the function.

jsacksick’s picture

Note that the patch from #3138952: Make the AvailabilityManager order aware also introduces new value objects for returning an availability result.

mglaman’s picture

Status: Needs review » Closed (outdated)

Fixed in #3138952: Make the AvailabilityManager order aware, which leverages a more robust AvailabilityManager API