What we'd like to accomplish is the ability for a Buy X Get Y promotion to result in the offer product automatically being added to the cart. This is a bit challenging, because the only "gets" conditions that are relevant here are "Specific products" (where only a single product is referenced and the product only has a single variation) or "Specific product variations" (where only a single product variation is referenced).

I'm not sure what the best option will be here to get this into the form, but my instinct says form states can be used to show / hide a checkbox labeled "Automatically add the offer product to the cart if it isn't in it already." Since we won't have a great way to determine if the reference fields for the two mentioned conditions have more than one value, we'll just need to put it in the description, "This behavior will only work when a single product variation (or a single product with only one variation) is specified."

There are going to be a handful of edge cases to address over time, but for now let's make sure that 1) if the free or discounted product is already in the cart that it is not automatically added again unless 2) the "gets" quantity is greater than the quantity in the cart, in which case the quantity of the existing order item should be increased.

CommentFileSizeAuthor
#46 interdiff_43-46.txt1.68 KBjsacksick
#46 3179312-46.patch49.43 KBjsacksick
#43 interdiff_41-43.txt884 bytesjsacksick
#43 3179312-43.patch49.3 KBjsacksick
#41 interdiff-41.txt7.58 KBamateescu
#41 3179312-41.patch49.09 KBamateescu
#39 3179312-39.patch51.56 KBjsacksick
#37 interdiff_36-37.txt2.52 KBjsacksick
#37 3179312-37.patch49.73 KBjsacksick
#36 interdiff_35-36.txt10.97 KBjsacksick
#36 3179312-36.patch52.57 KBjsacksick
#35 interdiff_33-35.txt18.77 KBjsacksick
#35 3179312-35.patch52.85 KBjsacksick
#33 interdiff_29-33.txt2.37 KBjsacksick
#33 3179312-33.patch44.5 KBjsacksick
#29 interdiff_27-29.txt7.24 KBjsacksick
#29 3179312-29.patch44.97 KBjsacksick
#27 interdiff_26-27.txt10.84 KBjsacksick
#27 3179312-27.patch41.34 KBjsacksick
#26 interdiff-26-try-to-clean-up-auto-added-order-items.txt6.27 KBamateescu
#26 interdiff-26.txt1.39 KBamateescu
#26 3179312-26.patch31.53 KBamateescu
#26 3179312-26-combined.patch36.72 KBamateescu
#21 interdiff-21.txt3.07 KBamateescu
#21 3179312-21.patch31.53 KBamateescu
#21 3179312-21-combined.patch36.72 KBamateescu
#20 interdiff-20.txt3.5 KBamateescu
#20 3179312-20.patch29.33 KBamateescu
#18 interdiff_17-18.txt567 bytesjsacksick
#18 3179312-18.patch26.81 KBjsacksick
#17 interdiff_15-17.txt3.45 KBjsacksick
#17 3179312-17.patch27.05 KBjsacksick
#17 order-item-wrong-quantity-discounted.png365.04 KBjsacksick
#15 behavior-help-text.png138.07 KBjsacksick
#15 interdiff_14-15.txt5.1 KBjsacksick
#15 3179312-15.patch25.8 KBjsacksick
#14 interdiff_13-14.txt3.54 KBjsacksick
#14 3179312-14.patch25.21 KBjsacksick
#14 error-with-valid-configuration.png202.56 KBjsacksick
#14 buy-x-get-y-autoadd-prefix.png192.84 KBjsacksick
#13 interdiff-13.txt4.56 KBamateescu
#13 3179312-13.patch24.08 KBamateescu
#12 interdiff-12.txt4.98 KBamateescu
#12 3179312-12.patch22.89 KBamateescu
#11 interdiff-11.txt6.97 KBamateescu
#11 3179312-11.patch17.91 KBamateescu
#7 interdiff_6-7.txt706 bytesjsacksick
#7 3179312-7.patch13.55 KBjsacksick
#6 3179312-6.patch13.17 KBjsacksick
#6 interdiff_2-6.txt2.7 KBjsacksick
#2 3179312.patch12.87 KBamateescu

Comments

rszrama created an issue. See original summary.

amateescu’s picture

Status: Active » Needs review
StatusFileSize
new12.87 KB

Here's a first pass at this. It's missing the new configuration option for enabling the auto-add behavior and test coverage, but it's otherwise ready for manual testing.

Note that the expected behavior for the case of "buy 2 get 1 free" of the same product is to _not_ increase the quantity automatically when the "buy" conditions are met (i.e. when adding a product with quantity of 2 to the cart, the quantity is not increased to 3 automatically).

The only case when the "get" product is added (and quantity increased) automatically is when the "buy" conditions are met by other products.

Status: Needs review » Needs work

The last submitted patch, 2: 3179312.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

jsacksick’s picture

+++ b/modules/promotion/src/Plugin/Commerce/PromotionOffer/BuyXGetY.php
@@ -76,7 +89,8 @@ class BuyXGetY extends OrderPromotionOfferBase {
       $container->get('commerce_cart.cart_manager'),

You're injecting the cart manager but not using it? Besides, the promotion module has no dependency on the cart module.

jsacksick’s picture

+++ b/modules/promotion/src/Plugin/Commerce/PromotionOffer/BuyXGetY.php
@@ -368,6 +409,86 @@ class BuyXGetY extends OrderPromotionOfferBase {
+          $purchasable_entities = $condition->getPurchasableEntities();

Should we maybe just add a return statement here, from the loop?

Something like return reset($condition->getPurchasableEntities()).

and we'd just return NULL at the end of the method?

jsacksick’s picture

Status: Needs work » Needs review
StatusFileSize
new13.17 KB
new2.7 KB

No credit here, just removed the cart manager, started adding comments to several methods introduced (I wasn't sure what to write for calculateExpectedGetQuantity()).

I just want to see if the tests are passing now.

jsacksick’s picture

StatusFileSize
new13.55 KB
new706 bytes
jsacksick’s picture

Issue tags: +Needs tests

I've been manually testing the patch, and the functionality seems to work as expected! (Tried manually adjusting the buy quantities back and forth to endure the right quantity gets discounted), tried removing the get item from the cart and it automatically gets re-added).

As mentioned in the comments we're missing a setting to enable automatically adding the get quantity, and tests, but the functionality seems to work as expected.

We'll probably need to do some bikeshedding for the new methods introduced and comments. but the new interface looks good.

It also seems we have "duplicate" (or at least very similar logic present in calculateExpectedGetQuantity()), I wonder if we could refactor some of it so it gets reused in the apply() method as well.

jsacksick’s picture

Also, we might have to remove the following line:

$order_item->set('order_id', $order->id());

because of https://www.drupal.org/project/commerce/issues/2656818#comment-12926347.

Basically, setting the order on the order item will trigger logic in the OrderItem::postSave() which will save the order (which can be very problematic from within the order refresh).

jsacksick’s picture

Status: Needs review » Needs work

I think this needs work as the BuyXGetY kernel test is failing (which is probably expected more or less since we're auto-adding the get quantity and the tests don't expect that.

But I'm suspecting that regressions were introduced as well. since testNotApplicable() is failing although this should probably not have been affected.

amateescu’s picture

Status: Needs work » Needs review
StatusFileSize
new17.91 KB
new6.97 KB

This patch adds the new setting, validation rules for it, and fixes the existing test coverage. All the failures from #7 were in test cases that did not expect the 'offered' item to be added to the order.

amateescu’s picture

StatusFileSize
new22.89 KB
new4.98 KB

And here's some initial test coverage too! I'm keeping the 'Needs tests' tag in case we want to add more test scenarios.

amateescu’s picture

Issue tags: -Needs tests
StatusFileSize
new24.08 KB
new4.56 KB

Expanded the tests a bit. I think they're pretty much complete now so I'm removing the tag.

jsacksick’s picture

StatusFileSize
new192.84 KB
new202.56 KB
new25.21 KB
new3.54 KB

I started testing the patch and noticed a small bug with the #states, when checking the "Specific products" condition and then the "Specific product variation" condition, if one of the 2 conditions is then unchecked, the auto add setting disappears, it could be a Drupal core bug, so we could probably leave it as is for now...

Additionally, UX wise, I wonder what we could do to outline the "Automatically add the offer product to the cart if it isn't in it already" setting so it does not look the same as all the checkboxes for each condition plugin, perhaps by setting a #prefix? (See the screenshot below):

Perhaps "Advanced" is not the right term, but added that just to demonstrate what I was referring to.

Besides that, found issues with the validation code. With valid conditions selected, I got the following error message: "The "auto-add" offer can only be enabled when a compatible "get" condition is used." (See screenshot below):

Also, when an invalid configuration is submitted, I also got the error message except that you end up being stuck since the "auto-add" checkbox is not present in the form (because of the #states) and there's simply no way to uncheck it. I believe we should simply set the auto get value to FALSE in this case.

The rest of the code looks good to me! I wish we could come up with a better name for findOrCreateOrderItem(), but I have no better idea at this point, perhaps we shouldn't overthink it since it's not a public method...

jsacksick’s picture

Issue summary: View changes
StatusFileSize
new25.8 KB
new5.1 KB
new138.07 KB

The attached patch is adding "Behavior" as an help text and addressing coding standard issues as well.

amateescu’s picture

Status: Needs review » Reviewed & tested by the community

Thanks @jsacksick, your changes look great to me! I assume you're ok with the rest of the code, so let's get this in :)

jsacksick’s picture

Status: Reviewed & tested by the community » Needs review
StatusFileSize
new365.04 KB
new27.05 KB
new3.45 KB

Ok, realized we needed to call the chain price resolver on the newly created order item to ensure the right price is resolved (in case somebody is using the price list module for example...), so adding that.

Also performed additional testing and wondering if the following behavior is correct (I'm suspecting a bug).
I have a promotion configured with Buy quantity 2 of any, get 2 of a specific product (with "auto add").

In my cart, I have 2 order items:

  1. The first order item has a quantity of 3
  2. The second order item is created/added to the cart (for the configured get product), with a quantity of 2.
  3. If I adjust the second order item quantity to 3, it gets automatically updated to 4, 3 are discounted, 1 quantity is charged.

Is that the expected behavior?

Also, my bad for suggesting to not set the order reference on the order item as it's causing a fatal error (i.e TaxTypeBase::resolveCustomerProfile() explodes because it's trying to access the order and it returns a NULL reference.

jsacksick’s picture

StatusFileSize
new26.81 KB
new567 bytes
jsacksick’s picture

Also, shouldn't we add an option to limit the number of times the get offer applies? Let's say you want to restrict the offer to a given quantity per order... (i.e buy 2 get 1, but only once...), I can see that being useful when the product being offered has limited quantity available (Can be implemented in a followup issue.

amateescu’s picture

StatusFileSize
new29.33 KB
new3.5 KB

This patch introduces a new offer_limit option to cover #19.

amateescu’s picture

StatusFileSize
new36.72 KB
new31.53 KB
new3.07 KB

This update adds support for locking auto-added order items, so customers can no longer edit their quantity or remove them from the cart. The support for locked order items is added in #3184272: Provide a way to lock order items, so this patch won't work by itself anymore, so I'm posting a combined patch as well.

The last submitted patch, 21: 3179312-21-combined.patch, failed testing. View results

Status: Needs review » Needs work

The last submitted patch, 21: 3179312-21.patch, failed testing. View results

rszrama’s picture

Test failure aside, one final issue with this patch is that if you're using condition plugins to determine BOGO applicability, a "Get Y" product might be auto-added after those conditions are met but not auto-removed if those conditions no longer apply. This is because we currently reverse apply the auto-add in BuyXGetY::apply(), which is only invoked after conditions have passed. To reproduce the problem, you can:

  1. Install a clean demo instance.
  2. Delete all existing promotions.
  3. Add a "Buy 1 Knit Hat in Green Get 1 Knit Hat in Midnight Plum for 100% off with auto-add" promotion. Add a condition on the order subtotal of greater than or equal to $30.
  4. Add a Knit Hat in Green to your cart. The promotion will not activate.
  5. Go to the cart page and increase its quantity to 2. The promotion will activate and the Knit Hat in Midnight Plum will be added to your cart.
  6. Reduce the Knit Hat in Green quantity to 1 or delete the order item entirely. The locked Knit Hat in Midnight Plum order item will remain, even if its discount is no longer applied.

This is really a small logic problem with a fix that may be more complicated. Basically, I think we need to reverse apply the auto-add in a broader scope - e.g. in PromotionOrderProcessor::process() when we recheck coupon validity. Or! We could go up another level to extend the logic of Order::clearAdjustments() to the resetting of quantity manipulations. This logically makes a lot of sense, it's just not technically an adjustment even if it is a like kind of order item manipulation.

The big question in my mind is if we can manipulate quantities at that point to 0 without creating a lot of extra deletes / saves in the same way we did by manipulating quantities in BuyXGetY::apply().

jsacksick’s picture

@rszrama: Should we assume the auto-added product needs to be automatically removed if the conditions are no longer met? Or does that need to be a promotion setting?

This is really a small logic problem with a fix that may be more complicated. Basically, I think we need to reverse apply the auto-add in a broader scope - e.g. in PromotionOrderProcessor::process() when we recheck coupon validity.

It makes sense for that logic to live in the Promotion order processor. Wondering if we shouldn't add a promotions reference field to orders (similar to the coupons reference, so we could copy what we're doing for coupons (i.e if a promotion is no longer available, call a method on the offer plugin to do the necessary cleanup (i.e remove the auto-added order item for example in that case?).

The new method we'd introduce could be implemented by PromotionOfferBase to ensure we don't introduce a breaking change, then this new offer plugin could implement the necessary logic.
We could call it clear() or clearPromotion()...

That'd be a generic fix, another less clean approach could be to loop on order items (prior to checking the promotion availability, and always set the quantity to 0, for auto added order items...

The big question in my mind is if we can manipulate quantities at that point to 0 without creating a lot of extra deletes / saves in the same way we did by manipulating quantities in BuyXGetY::apply().

The order refresh logic already takes care of saving order items that have changed during the refresh process, so we can safely update the quantity without necessarily resaving the order item. But updating the quantity to 0 is not going to remove the order item from the order?

amateescu’s picture

The combined patch in #21 is failing a test because we haven't updated the new test coverage to the expectation that we should delete the auto-added order items, so here's a fix for that.

Also, I tried to implement the suggestion for cleaning up auto-added order items in OromotionOrderProcessor::process() (see additional interdiff attached), but this approach can't work because, when refreshing the order, Order::clearAdjustments() (which removes the discount applied to the auto-added order item) is called before the promotion order processor, therefore the "running" order total (the value between the calls to Order::clearAdjustments() and OromotionOrderProcessor::process()) might be higher than the value used for the condition on the order total, so the promotion would still apply.

Here's a quick steps to reproduce:
- use a product with 2 variations, the first with a price of $10 and the second with a price of $15
- configure a BXGY condition like this:
- buy 1 "first variation" (the $10 one)
- get 1 "second variation" (the $15 one)
- auto-add the get product to the cart
- add condition on "Current order total" to be equal or greater than $15

And what happens is this:
- add 1 "first variation" to the cart -> the promotion doesn't apply (order total is $10)
- increase the quantity to 2 -> the promotion applies and it adds two "second variation" order items, 100% discounted (order total is $20)
- decrease the quantity back to 1 -> at the end of Order::clearAdjustments() the order total is $40 (!! $10 for the "first variation" item and $15 + $15 for the two now non-discounted "second variation" items)
- because the promotion can't remove its auto-added order items before Order::clearAdjustments(), it will still apply based on the "intermediary" total price of $40, and it will only remove only one "second variation" order item

All that to say.. it still needs a bit more thought, but I wanted to share this WIP anyway. Maybe we could somehow clear the auto-added order items *before* trying to apply the promotions in PromotionOrderProcessor::process().

jsacksick’s picture

Status: Needs work » Needs review
StatusFileSize
new41.34 KB
new10.84 KB

I've been working extensively on this today and had several discussions with @amateescu.

I rerolled the patch and wrote a failing test reproducing the following:

- use a product with 2 variations, the first with a price of $10 and the second with a price of $15
- configure a BXGY condition like this:
- buy 1 "first variation" (the $10 one)
- get 1 "second variation" (the $15 one)
- auto-add the get product to the cart
- add condition on "Current order total" to be equal or greater than $15

So as explained in #26, the main problem is that the auto-added order item is taken into account in the order total calculation, which is causing the promotion to apply after the promotion adjustments are cleared by $order->clearAdjustments().

There's no need for the "combined" patch anymore since #3184272: Provide a way to lock order items got committed.

The clear() method approach "almost" works, but not entirely due to the issue with the order total calculation.

I have an idea that could make this work which would consist in "locking" the promotion adjustment, in order for it to not be removed when $order->clearAdjustments()is called.

Attaching a first patch that's failing, and will attempt a second approach that locks the adjustments.

I believe the adjustment should remain otherwise that could cause issues with any promotions testing the order total...

Status: Needs review » Needs work

The last submitted patch, 27: 3179312-27.patch, failed testing. View results

jsacksick’s picture

Status: Needs work » Needs review
StatusFileSize
new44.97 KB
new7.24 KB

The attached patch seems to do the trick (tests are passing for me locally). The adjustments added by the "buy x get y" offer are now "locked", and are cleaned at the beginning of the apply() method.

This way, if a promotion no longer applies, the apply() method won't even get called.

The remaining auto added order item is correctly cleaned by the logic in the promotion order processor.

jsacksick’s picture

Note that we're probably missing a setting to toggle whether the auto-added order item gets automatically removed....

@rszrama, @amateescu: Should we implement that now? Or make do that in a followup issue?

jsacksick’s picture

Shoot, only ran the BuyXGetYTest locally, other promotions tests are failing.

Status: Needs review » Needs work

The last submitted patch, 29: 3179312-29.patch, failed testing. View results

jsacksick’s picture

Status: Needs work » Needs review
StatusFileSize
new44.5 KB
new2.37 KB

Now that we're locking adjustments, we can actually rely on $order->collectAdjustments()to detect whether a previously applied promotion no longer applies. Note that this means that potential other promotion offers would need to lock adjustments in a similar fashion to have their clear() method invoked.

This makes me wonder whether we should remove the introduced clear()method and have specific code this just for the "buy x get y" offer...

jsacksick’s picture

Status: Needs review » Needs work

We need to clear the locked adjustments in a different place than from the apply() method, otherwise locked adjustments will remain...

Perhaps from the clear method? Additionally, we're not clearing them properly right now (my latest code for clearing adjustments is incorrect).

jsacksick’s picture

StatusFileSize
new52.85 KB
new18.77 KB

Definitely not a trivial issue :), I'm attaching my WIP that unfortunately breaks Promotion compatibility checks... (which is expected).

I also introduced the auto remove setting which has tests coverage.

Keeping the locked adjustments is causing issues, but on the other hand not keeping it affects the order total, causing any condition on the order total to now work as expected...

To make this simpler, I'm wondering if we shouldn't set the order item unit price of the order item we're auto adding to 0. The problem is the promotion is no longer going to appear in the total summary...

Will probably try a different approach later today.

jsacksick’s picture

Status: Needs work » Needs review
StatusFileSize
new52.57 KB
new10.97 KB

The attached patch should fix the remaining issues (Although we probably need more tests).

We now clear the promotion adjustments really early in the process, via a newly introduced EarlyOrderProcessor that has a high priority.

This clears the promotion adjustments, and also reduces the quantity (i.e removes the auto added quantity).

OrderRefresh is now taking care of the order item removal in case the order item quantity at the end of the refresh process is 0, we probably need tests for that, although BuyXGetY::testAutoRemoveOrderItem() kind of test that.

Also, after discussing this with @amateescu, we've decided there was no point in a new setting for toggling the auto-removal of the automatically added quantity (this should always happen).

jsacksick’s picture

StatusFileSize
new49.73 KB
new2.52 KB

Removed the cosmetic changes to PromotionOrderProcessor which got committed separately.

The last submitted patch, 36: 3179312-36.patch, failed testing. View results

jsacksick’s picture

StatusFileSize
new51.56 KB

Forgot to commit the EarlyOrderProcessor (explaining the amount of failing tests) :).

The last submitted patch, 37: 3179312-37.patch, failed testing. View results

amateescu’s picture

StatusFileSize
new49.09 KB
new7.58 KB

Did a bunch of cleanups after reviewing the recent changes, and I think this is finally ready!

amateescu’s picture

Saving credits for @rszrama :)

Edit: or not, since I'm not a maintainer :P

jsacksick’s picture

StatusFileSize
new49.3 KB
new884 bytes

You removed too much code from the clear() method :).

The last submitted patch, 41: 3179312-41.patch, failed testing. View results

amateescu’s picture

Status: Needs review » Reviewed & tested by the community

Oops :D

jsacksick’s picture

Status: Reviewed & tested by the community » Needs review
StatusFileSize
new49.43 KB
new1.68 KB

Tests are still failing :).

The last submitted patch, 43: 3179312-43.patch, failed testing. View results

  • jsacksick committed 442e06b on 8.x-2.x authored by amateescu
    Issue #3179312 by jsacksick, amateescu, rszrama: Add an option to Buy X...
jsacksick’s picture

Status: Needs review » Fixed

Committed!!! Good work guys!

  • jsacksick committed 0b5799a on 8.x-2.x
    Issue #3179312 followup by jsacksick: Resolve the unit price on each...
jsacksick’s picture

I just committed a followup that requires tests coverage.

Status: Fixed » Closed (fixed)

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