When applying a coupon through the coupon redemption form we can check the coupon availability. However, it is hard to do this over an API context or to check if a coupon is still available for an order.

In \Drupal\commerce_promotion\Plugin\Commerce\InlineForm\CouponRedemption::validateInlineForm we have:

    if (!$coupon->available($order)) {
      $form_state->setErrorByName($coupon_code_path, t('The provided coupon code is invalid.'));
      return;
    }
    if (!$coupon->getPromotion()->applies($order)) {
      $form_state->setErrorByName($coupon_code_path, t('The provided coupon code is invalid.'));
      return;
    }

And in \Drupal\commerce_promotion\PromotionOrderProcessor::process we have:

      if ($coupon->available($order) && $promotion->applies($order)) {
        $promotion->apply($order);
      }
      else {
        // The promotion is no longer available (end date, usage, etc).
        $order->get('coupons')->removeItem($index);
      }

There isn't an easy way to validate coupons on the order.

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

mglaman created an issue. See original summary.

mglaman’s picture

Status: Active » Needs review
FileSize
6.86 KB

This adds a constraint to the coupon field which validates if the coupon and promotion apply. If it does not, then a constraint is returned.

This allows validating an order to see if coupons apply or not without blindly applying them and hope they persist, and trying to catch if they'll be removed on the next order refresh.

bojanz’s picture

Looks good!

+    if (!$value instanceof EntityReferenceFieldItemListInterface) {
+      $this->context->addViolation('Value was empty of %type', ['%type' => get_class($value)]);
+      return;
+    }

Super confused by this though.

mglaman’s picture

I think it's just a check I added in case someone attached it on an incorrect base field. I shouldn't do that, just add an assert

mglaman’s picture

Removed that for an assert

bojanz’s picture

Status: Needs review » Needs work

#5 is not a patch for this issue.

Also, the validator currently checks coupon availability even if the order has already been placed, which is incorrect.

mglaman’s picture

Status: Needs work » Needs review
FileSize
4.14 KB

Whoops, sorry. Too much multitasking.

Here is a proper patch and it covers placed orders.

mglaman’s picture

FileSize
7.35 KB

I don't know how to upload patches.

mglaman’s picture

The next step here is to 👍 the approach and replace the two areas highlighted in the issue summary to run validation against the field and set the error versus running the logic separately.

bojanz’s picture

I would prefer that the inline form stays as-is. It is cleaner.

PromotionOrderProcessor should be converted.

mglaman’s picture

Sounds good, and makes sense - why add it to the order to run validation in the inline form when it's available right there. I'll update PromotionOrderProcessor. This made me realize we have no context as to what code.

In the entity reference constraint code I saw we can specify the field item delta using this snippet:

          $this->context->buildViolation($constraint->nullMessage)
            ->atPath((string) $delta)
            ->addViolation();
mglaman’s picture

Here is an updated patch. Turns out PromotionOrderProcessorTest never tested the removal of coupons. I added that to test the changes worked as expected.

tofasuriawan’s picture

I faced an issue after applying patch #12, my local installation showing error Unable to remove item at non-existing index. on this line:
$order->get('coupons')->removeItem($constraint->getPropertyPath());

This happened after I delete applied coupon code in promotion setting without removing coupon in order first.

Step to reproduce the issue:

  1. Apply patch
  2. Create coupon code
  3. Apply coupon code to order
  4. Delete coupon code in promotion setting
  5. Wait until order refresh process triggered
  6. Access admin order page (admin/commerce/orders)
mglaman’s picture

Status: Needs review » Needs work

Oh, interesting! That was not considered. Thanks.

tofasuriawan’s picture

After further checking, removeItem() expect an integer parameter, but get string from getPropertyPath() like '0.target_id' or '1.target_id' which is not an index on the coupon list.

My solution is convert $constraint->getPropertyPath() to integer then apply removeItem(). Mine works, but I am not sure that is a good approach. I was wondering if you would give some suggestions. Thank you

  public function process(OrderInterface $order) {
    $constraints = $order->get('coupons')->validate();
    /** @var \Symfony\Component\Validator\ConstraintViolationInterface $constraint */
    foreach ($constraints as $constraint) {
      $violation = (int) $constraint->getPropertyPath();
      if (!isset($violation) || $violation < 0) {
        continue;
      }
      $order->get('coupons')->removeItem($violation);
    }
mglaman’s picture

Assigned: Unassigned » mglaman

Coming back around to this.

mglaman’s picture

The error was due to constraints existing from ValidReference

          $this->context->buildViolation($message)
            ->setParameter('%type', $target_type_id)
            ->setParameter('%id', $target_id)
            ->atPath((string) $delta . '.target_id')
            ->setInvalidValue($target_id)
            ->addViolation();

It sets the property path to the field properties of target_id or entity

mglaman’s picture

Assigned: mglaman » Unassigned
Status: Needs work » Needs review
FileSize
12.26 KB

This addresses #13. The coupon constraint now has the same path setup as invalid reference constraints. As a bonus, this order processor now ensures deleted coupons are removed from the order.

mglaman’s picture

Fix the property path in the assertion.

mglaman’s picture

Status: Needs review » Needs work

Tests are no longer passing.

mglaman’s picture

Status: Needs work » Needs review
FileSize
12.19 KB

Switched the kernel test to OrderKernelTestBase for schema fix.

mglaman’s picture

jsacksick’s picture

Status: Needs review » Reviewed & tested by the community

I haven't manually tested the patch, but it looks really good! I probably would have picked a different name for the constraint plugin (Something like "CouponValidConstraint" but it matches the available() method on the coupon class, so I think this makes sense, though we could have named it "CouponAvailabilityConstraint" as well.

mglaman’s picture

Assigned: Unassigned » mglaman
Status: Reviewed & tested by the community » Needs work
+++ b/modules/promotion/src/Plugin/Validation/Constraint/CouponAvailableConstraint.php
@@ -0,0 +1,26 @@
+  public $message = 'The coupon %code is not available for this order.';

As bojanz pointed out, currently the redemption form uses this wording

The provided coupon code is invalid.

Lets use that.

mglaman’s picture

Assigned: mglaman » Unassigned
Status: Needs work » Needs review
FileSize
3.18 KB
11.63 KB

Adjusted the wording.

mglaman’s picture

+++ b/modules/promotion/src/Plugin/Validation/Constraint/CouponAvailableConstraintValidator.php
@@ -0,0 +1,39 @@
+          ->atPath((string) $delta . '.target_id')

The one thing I wanted to double check was this line. Core uses this as well in ValidReferenceConstraintValidator.

// for entity property / new entities
          ->atPath((string) $delta . '.entity')
          ->setInvalidValue($entity)
// for target_id property
            ->atPath((string) $delta . '.target_id')
            ->setInvalidValue($target_id)
mglaman’s picture

+++ b/modules/promotion/commerce_promotion.module
@@ -140,6 +140,7 @@ function commerce_promotion_entity_base_field_info(EntityTypeInterface $entity_t
+      ->addConstraint('CouponAvailable')

+++ b/modules/promotion/src/Plugin/Validation/Constraint/CouponAvailableConstraint.php
@@ -0,0 +1,26 @@
+ * Coupon valid reference constraint.
...
+ *   id = "CouponAvailable",
+ *   label = @Translation("Coupon available reference", context = "Validation")
...
+class CouponAvailableConstraint extends Constraint {
...
+  public $message = 'The provided coupon code is invalid.';

bojanz pointed out the verbs are all over the place.

This checks available & applies on coupons. So it should be called CouponValid.

mglaman’s picture

s/CouponAvailable/CouponValid

mglaman’s picture

Status: Needs review » Fixed

🥳 merged.

bojanz’s picture

Status: Fixed » Needs work

Let's do a followup.

       // Remove coupons which are no longer valid (due to availability/conditions).
+    $constraints = $order->get('coupons')->validate();
+    $coupons_field_list = $order->get('coupons');
+    /** @var \Symfony\Component\Validator\ConstraintViolationInterface $constraint */
+    foreach ($constraints as $constraint) {
+      list($delta, $property_name) = explode('.', $constraint->getPropertyPath());
+      $coupons_field_list->removeItem($delta);
+    }

Can we put a one-line comment above this block to explain why we're doing this?
Something like "Remove coupons which are no longer valid (availability/conditions)."

+/**
+ * Tests the coupon valid constraint on coupons fields.
+ *
+ * @group commerce
+ */
+class CouponValidConstraintValidatorTest extends OrderKernelTestBase {

I would expect a test for CouponValidConstraintValidator to have a @coversDefaultClass pointing to it, and the test not to need the full order refresh.

+  /**
+   * Tests the order refresh to remove coupons from an order when invalid.
+   */
+  public function testCouponRemoval() {

Doesn't this belong in PromotionOrderProcessorTest, since that's where the removal is done?

mglaman’s picture

I would expect a test for CouponValidConstraintValidator to have a @coversDefaultClass pointing to it, and the test not to need the full order refresh.

I can add the covers annotations. With testCouponRemoval moved to PromotionOrderProcessorTest there's no refresh needed.

    $order->setRefreshState(Order::REFRESH_SKIP);
    $order->save();

That exists so that the store_id constraint doesn't fire, because of entity access checks. And also for recording promotion usage.

mglaman’s picture

Status: Needs work » Needs review
FileSize
6.64 KB

Follow up. Should be OK but want DrupalCI to verify.

mglaman’s picture

+++ b/modules/promotion/tests/src/Kernel/CouponValidConstraintValidatorTest.php
@@ -13,6 +13,7 @@ use Drupal\Tests\commerce_order\Kernel\OrderKernelTestBase;
+ * @coversDefaultClass \Drupal\commerce_cart_api\Plugin\Validation\Constraint\CouponValidConstraintValidator

🤦‍♂️

mglaman’s picture

Status: Needs review » Fixed

Committed the follow up

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.