Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

Omar_Hamwi's avatar

Buy X Get Y Offer logic

hi so i have a problem in my laravel code i am making a discount method that is like buy x get y that shopify have but i am having a problem first i am going to explain the offer the offer is buy 2 items and the third one in the cart when you add it it will be discounted here is my xyoffer model that have the data

class Xyoffer extends Model
{
    use HasFactory;
    protected $fillable = [
        'name',
        'description',
        'type',
        'rule',
        'buy_model_type',
        'buy_models',
        'buy_items',
        'buy_amount',
        'get_model_type',
        'get_models',
        'get_items',
        'get_amount',
        'discount_percentage',
        'discount_amount',
        'status',
    ];
    protected $casts = [
        'buy_models' => 'array',
        'buy_items' => 'array',
        'get_items' => 'array',
        'get_models' => 'array',
    ]; 
}

the offer that i stored is buy 2 get 1 now it is working good when the items are different and when i only add 3 items 1 is getting discount and 2 are normal now the problem is happening when i add one more item (note: this only happen if the shopify_product_id is in the buy_items and the get_items if they are not it works fine) when adding one more which means that the total quantity of the items that can be used is 4 the offer is getting canceled and all the items are back to there original state how can i fix this please help this is the code that is making the discount

class XyOfferAction
{
    use AsAction;

    public function handle($cart)
    {
        DB::beginTransaction();

        try {
            // Reset all items in the cart to their normal state
            $this->removeInvalidDiscounts($cart);

            // Fetch all active offers
            $offers = XyOffer::all();

            // Apply each offer to the cart
            foreach ($offers as $offer) {
                $this->applyOffer($cart, $offer);
            }

            // Consolidate cart items after applying offers
            $this->consolidateCartItems($cart);

            // Commit the transaction
            DB::commit();
        } catch (\Exception $e) {
            // Rollback the transaction in case of any errors
            DB::rollBack();
            throw $e;
        }
    }

    protected function removeInvalidDiscounts(Cart $cart)
    {
        // Load cart items to ensure we're working with the latest data
        $cart->load('cartItems');

        // Reset all items to their normal price and remove any discount flags
        foreach ($cart->cartItems as $item) {
            $item->price = $item->compare_at; // Assume compare_at holds the normal price
            $item->total_price = $item->price * $item->quantity;
            $item->is_discounted = false; // Reset the discount flag
            $item->save();
        }

        // Update the cart's totals after resetting prices
        $cart->updateTotals();
    }


    protected function applyOffer(Cart $cart, XyOffer $offer)
    {
        $buyItemIds = $offer->buy_items;
        $getItemIds = $offer->get_items;

        // Retrieve the 'buy' items from the cart
        $buyItemsInCart = $cart->cartItems()->whereIn('shopify_product_id', $buyItemIds)->get();
        $buyQuantity = $buyItemsInCart->sum('quantity');
        

        // Calculate the number of offers that can be applied based on 'buy' quantity
        $offerCount = intdiv($buyQuantity, $offer->buy_amount);

        // If the offer is applicable
        if ($offerCount > 0) {
            // Check if the buy items and get items are the same
            $sameItemForBuyAndGet = $buyItemIds == $getItemIds;

            if ($sameItemForBuyAndGet) {
                // Adjust the buyQuantity to exclude the quantities used to trigger the offer
                $buyQuantity -= ($offerCount * $offer->buy_amount);
            }

            // Retrieve the 'get' items from the cart
            $getItemsInCart = $cart->cartItems()->whereIn('shopify_product_id', $getItemIds)->get();

            foreach ($getItemsInCart as $item) {
                
                // Calculate the quantity eligible for discount
                $eligibleForDiscount = $sameItemForBuyAndGet ? $buyQuantity : $item->quantity;
                // Calculate how many items can be discounted considering the already discounted ones
                $discountableQuantity = min($eligibleForDiscount, ($offerCount * $offer->get_amount));

                if ($discountableQuantity > 0) {
                    // Apply discount to these items
                    $this->discountItem($item, $discountableQuantity, $offer);

                    if ($sameItemForBuyAndGet) {
                        // Reduce the available quantity for discounting for the next iteration
                        $buyQuantity -= $discountableQuantity;
                    }
                }

                // Calculate any remaining 'get' items that are not eligible for discount
                $nonDiscountableQuantity = $item->quantity - $discountableQuantity;

                // If there are such items
                if ($nonDiscountableQuantity > 0) {
                    // Set the price for these items to their normal amount
                    $this->applyNormalPrice($item, $nonDiscountableQuantity);
                }
            }
        }

        // After applying all offers, update the cart's totals
        $cart->updateTotals();
    }


    protected function applyNormalPrice(CartItem $item, $nonDiscountableQuantity)
    {
        // If there's a split item, we need to update the non-discounted item's quantity and price
        if ($nonDiscountableQuantity > 0 && $item->is_discounted) {
            // Create a new CartItem for the non-discounted quantity
            $normalItem = $item->replicate();
            $normalItem->quantity = $nonDiscountableQuantity;
            $normalItem->is_discounted = false;
            $normalItem->price = $normalItem->compare_at;
            $normalItem->total_price = $normalItem->price * $nonDiscountableQuantity;
            $normalItem->save();
        }
    }

    protected function discountItem(CartItem $item, $quantityToDiscount, XyOffer $offer)
    {
        \Log::debug('Starts Discounting');
        // If we are discounting fewer items than the item's quantity
        if ($quantityToDiscount < $item->quantity) {
            // Create a new CartItem for the non-discounted quantity
            $nonDiscountedItem = $item->replicate();
            $nonDiscountedItem->quantity = $item->quantity - $quantityToDiscount;
            $nonDiscountedItem->is_discounted = false;
            $nonDiscountedItem->price = $nonDiscountedItem->compare_at;
            $nonDiscountedItem->total_price = $nonDiscountedItem->price * $nonDiscountedItem->quantity;
            $nonDiscountedItem->save();

            // Update the original item with the discounted quantity
            $item->quantity = $quantityToDiscount;
        }

        // Apply the discount to the original item
        $item->is_discounted = true;
        if ($offer->discount_percentage == 100) {
            $item->price = 0;
            $item->total_price = 0;
        } else {
            $item->price *= (1 - $offer->discount_percentage / 100);
            $item->total_price = $item->price * $item->quantity;
        }
        $item->save();

        // After saving the discounted item, store its information in the session
        $discountedItemInfo = [
            'shopify_product_id' => $item->shopify_product_id,
            'title'              => $item->title,
            'quantity'           => $quantityToDiscount,
            'message'            => 'You have received a discount!', // Custom message
            'in_use'             => 1,
        ];

        // Retrieve the existing array of discounted items from the session, or initialize a new one if it doesn't exist
        $discountedItems = session('discounted_items', []);

        $existingItemKey = array_search($item->shopify_product_id, array_column($discountedItems, 'shopify_product_id'));

        if ($existingItemKey !== false && $discountedItems[$existingItemKey]['quantity'] === $quantityToDiscount) {
            // Item exists in the session with the same quantity, do not re-add it
        } else {
            // Item does not exist or quantity has changed, add/update the item information in the session
            $discountedItems[$existingItemKey] = $discountedItemInfo;

            // Put the updated array back into the session
            session(['discounted_items' => $discountedItems]);
        }

        // \Log::info('Discounted items in session:', session('discounted_items', []));
    }

    protected function consolidateCartItems(Cart $cart)
    {
        // Group items by shopify_variant_id and is_discounted flag
        $groupedItems = $cart->cartItems()
            ->get()
            ->groupBy(function ($item) {
                return $item->shopify_variant_id . '-' . $item->is_discounted;
            });

        foreach ($groupedItems as $groupKey => $items) {
            // If there's more than one item in the group, consolidate them
            if ($items->count() > 1) {
                $firstItem = $items->shift(); // Keep the first item

                // Sum quantities and total_prices, then delete the other items
                $totalQuantity = $items->sum('quantity') + $firstItem->quantity;
                $totalPrice = $items->sum('total_price') + $firstItem->total_price;

                // Update the first item with the new total quantity and price
                $firstItem->quantity = $totalQuantity;
                $firstItem->total_price = $totalPrice;
                $firstItem->save();

                // Delete the other items
                foreach ($items as $item) {
                    $item->delete();
                }
            }
        }

        // Update the cart's totals after consolidation
        $cart->updateTotals();
    }
}
0 likes
1 reply
martinbean's avatar
Level 80

@omar_hamwi If you’re trying to model something, then a good first step is to look some place where this has already been done. You’ve already mentioned Shopify, so you can check Shopify’s API docs to see how they model requests for creating a discount.

If you do go to Shopify’s docs, then they have an example request for exactly what you’re describing: a Buy X Get Y price rule: https://shopify.dev/docs/api/admin-rest/2023-10/resources/pricerule#post-price-rules-examples

So looking at the request body, they model the rule like this:

  • entitled_product_ids (an array, which you would store as a has-many relation)
  • prerequisite_collection_ids (an array, again stored as a has-many relation)
  • prerequisite_quantity (the number of X to get Y)
  • entitled_quantity (the number of Y the customer would receive if the prerequisites are met)

Shopify have various separate fields for entitlements and prerequisites (they have separate fields for entitled products, entitled variants, entitled collections, etc). You could use some sort of polymorphic relation there instead.

1 like

Please or to participate in this conversation.