Repository: saleor/saleor
Pull request: Fix invalid discount calculaion for specific product voucher that is applied once per order
For a specific product voucher with the "apply once per order" flag set to true, the discount is applied to the cheapest line item, rather than the cheapest line item that the voucher covers.

Fixes https://github.com/saleor/saleor/issues/12107

<!-- Please mention all relevant issue numbers. -->

# Impact

* [ ] New migrations
* [ ] New/Updated API fields or mutations
* [ ] Deprecated API fields or mutations
* [ ] Removed API types, fields, or mutations
* [ ] Documentation needs to be updated

# Pull Request Checklist

<!-- Please keep this section. It will make the maintainer's life easier. -->

* [ ] Privileged queries and mutations are guarded by proper permission checks
* [ ] Database queries are optimized and the number of queries is constant
* [ ] Database migration files are up to date
* [ ] The changes are tested
* [ ] GraphQL schema and type definitions are up to date
* [ ] Changes are mentioned in the changelog


Problem statement:
Bug: Discount applied on wrong line for specific product voucher with apply once per order set to True 
### What are you trying to achieve?

For a specific product voucher with the "apply once per order" flag set to true, the discount is applied to the cheapest line item, rather than the cheapest line item that the voucher covers.

### Steps to reproduce the problem

1. Create a specific voucher discount with the "apply once per order" flag set to true.
2. Create a checkout process with two products, where only the more expensive product is included in the discount and the cheaper product is not included.
3. Attach the voucher code to the discount.
4. Check the lines total and undiscounted total price - the discount is applied to the cheapest line, instead of line that is included in the discount

### What did you expect to happen?

The discount should be applied on the line that the discount cover, not just the cheapest one.

### Logs

_No response_

### Environment

Saleor version: 3.9 +

Hints from issue discussion:

Commit message: Fix invalid discount calculaion for specific product voucher that is applied once per order

Patch to review:
diff --git a/saleor/checkout/fetch.py b/saleor/checkout/fetch.py
index 207b740d2be..c387a37184a 100644
--- a/saleor/checkout/fetch.py
+++ b/saleor/checkout/fetch.py
@@ -360,33 +360,46 @@ def apply_voucher_to_checkout_line(
 
     voucher = voucher_info.voucher
     discounted_lines_by_voucher: List[CheckoutLineInfo] = []
-    if voucher.apply_once_per_order:
-        channel = checkout.channel
-        cheapest_line_price = None
-        cheapest_line = None
-        for line_info in lines_info:
-            line_price = line_info.variant.get_price(
-                product=line_info.product,
-                collections=line_info.collections,
-                channel=channel,
-                channel_listing=line_info.channel_listing,
-                discounts=discounts,
-                price_override=line_info.line.price_override,
-            )
-            if not cheapest_line or cheapest_line_price > line_price:
-                cheapest_line_price = line_price
-                cheapest_line = line_info
-        if cheapest_line:
-            discounted_lines_by_voucher.append(cheapest_line)
-    else:
+    lines_included_in_discount = lines_info
+    if voucher.type == VoucherType.SPECIFIC_PRODUCT:
         discounted_lines_by_voucher.extend(
             get_discounted_lines(lines_info, voucher_info)
         )
+        lines_included_in_discount = discounted_lines_by_voucher
+    if voucher.apply_once_per_order:
+        cheapest_line = _get_the_cheapest_line(
+            checkout, lines_included_in_discount, discounts
+        )
+        if cheapest_line:
+            discounted_lines_by_voucher = [cheapest_line]
     for line_info in lines_info:
         if line_info in discounted_lines_by_voucher:
             line_info.voucher = voucher
 
 
+def _get_the_cheapest_line(
+    checkout: "Checkout",
+    lines_info: Iterable[CheckoutLineInfo],
+    discounts: Iterable["DiscountInfo"],
+):
+    channel = checkout.channel
+    cheapest_line_price = None
+    cheapest_line = None
+    for line_info in lines_info:
+        line_price = line_info.variant.get_price(
+            product=line_info.product,
+            collections=line_info.collections,
+            channel=channel,
+            channel_listing=line_info.channel_listing,
+            discounts=discounts,
+            price_override=line_info.line.price_override,
+        )
+        if not cheapest_line or cheapest_line_price > line_price:
+            cheapest_line_price = line_price
+            cheapest_line = line_info
+    return cheapest_line
+
+
 def fetch_checkout_info(
     checkout: "Checkout",
     lines: Iterable[CheckoutLineInfo],
diff --git a/saleor/checkout/tests/test_checkout.py b/saleor/checkout/tests/test_checkout.py
index f1fa9a13b2b..bce9743a1db 100644
--- a/saleor/checkout/tests/test_checkout.py
+++ b/saleor/checkout/tests/test_checkout.py
@@ -487,7 +487,8 @@ def test_get_discount_for_checkout_specific_products_voucher_apply_only_once(
     ).gross
 
     # then
-    assert any([line.voucher is not None for line in lines])
+    assert [line.voucher is not None for line in lines].count(True) == 1
+
     for line in lines:
         line.voucher = None
     subtotal_without_voucher = manager.calculate_checkout_subtotal(
@@ -1380,7 +1381,7 @@ def test_is_fully_paid(checkout_with_item, payment_dummy):
     assert is_paid
 
 
-def test_is_fully_paid_many_payments(checkout_with_item, payment_dummy):
+def test_is_fully_paid_mg_payments(checkout_with_item, payment_dummy):
     checkout = checkout_with_item
     manager = get_plugins_manager()
     lines, _ = fetch_checkout_lines(checkout)
diff --git a/saleor/graphql/checkout/tests/mutations/test_checkout_add_promo_code.py b/saleor/graphql/checkout/tests/mutations/test_checkout_add_promo_code.py
index fbae1c0f586..1fae17581fb 100644
--- a/saleor/graphql/checkout/tests/mutations/test_checkout_add_promo_code.py
+++ b/saleor/graphql/checkout/tests/mutations/test_checkout_add_promo_code.py
@@ -13,6 +13,7 @@
 from .....checkout.utils import add_variant_to_checkout, set_external_shipping_id
 from .....discount import VoucherType
 from .....plugins.manager import get_plugins_manager
+from .....product.models import ProductVariantChannelListing
 from .....warehouse.models import Stock
 from ....core.utils import to_global_id_or_none
 from ....tests.utils import get_graphql_content
@@ -543,6 +544,78 @@ def test_checkout_add_category_code_checkout_with_sale(
     assert checkout.discount_amount == Decimal(0)
 
 
+def test_checkout_add_variant_voucher_code_apply_once_per_order(
+    api_client, checkout_with_items, voucher_specific_product_type
+):
+    # TODO: update third variant price - include the third variant in discount
+    # given
+    checkout = checkout_with_items
+    channel = checkout.channel
+
+    lines = checkout.lines.all()
+    checkout.lines.last().delete()
+    variant_1, variant_2, variant_3 = [line.variant for line in lines]
+    variant_1_listing = variant_1.channel_listings.get(channel=channel)
+    variant_2_listing = variant_2.channel_listings.get(channel=channel)
+    variant_3_listing = variant_3.channel_listings.get(channel=channel)
+
+    variant_1_price = Decimal(10)
+    variant_2_price = Decimal(25)
+    variant_3_price = Decimal(20)
+    variant_1_listing.price_amount = variant_1_price
+    variant_2_listing.price_amount = variant_2_price
+    variant_3_listing.price_amount = variant_3_price
+
+    ProductVariantChannelListing.objects.bulk_update(
+        [variant_1_listing, variant_2_listing, variant_3_listing], ["price_amount"]
+    )
+
+    voucher = voucher_specific_product_type
+    voucher.apply_once_per_order = True
+    voucher.save(update_fields=["apply_once_per_order"])
+
+    voucher_listing = voucher.channel_listings.get(channel=channel)
+    discount_value = 20
+    voucher_listing.discount_value = discount_value
+    voucher_listing.save(update_fields=["discount_value"])
+
+    voucher.variants.set([variant_2, variant_3])
+    voucher.products.clear()
+
+    expected_discount = Money(
+        variant_3_price * (Decimal(discount_value) / 100), checkout.currency
+    )
+
+    manager = get_plugins_manager()
+    lines, _ = fetch_checkout_lines(checkout)
+    checkout_info = fetch_checkout_info(checkout, lines, [], manager)
+    checkout.price_expiration = timezone.now()
+    subtotal_before_voucher = calculations.checkout_subtotal(
+        manager=manager,
+        checkout_info=checkout_info,
+        lines=lines,
+        address=checkout.shipping_address,
+        discounts=[],
+    )
+
+    variables = {
+        "id": to_global_id_or_none(checkout),
+        "promoCode": voucher.code,
+    }
+
+    # wheng
+    data = _mutate_checkout_add_promo_code(api_client, variables)
+
+    # then
+    checkout.refresh_from_db()
+    lines, _ = fetch_checkout_lines(checkout)
+    checkout_info = fetch_checkout_info(checkout, lines, [], manager)
+    assert not data["errors"]
+    assert checkout.voucher_code == voucher.code
+    assert checkout.discount_amount == Decimal(0)
+    assert checkout.subtotal + expected_discount == subtotal_before_voucher
+
+
 def test_checkout_add_voucher_code_not_applicable_voucher(
     api_client, checkout_with_item, voucher_with_high_min_spent_amount
 ):


Code review comments:
diff_hunk: @@ -543,6 +544,78 @@ def test_checkout_add_category_code_checkout_with_sale(
     assert checkout.discount_amount == Decimal(0)
 
 
+def test_checkout_add_variant_voucher_code_apply_once_per_order(
+    api_client, checkout_with_items, voucher_specific_product_type
+):
+    # TODO: update third variant price - include the third variant in discount
+    # given
+    checkout = checkout_with_items
+    channel = checkout.channel
+
+    lines = checkout.lines.all()
+    checkout.lines.last().delete()
+    variant_1, variant_2, variant_3 = [line.variant for line in lines]
+    variant_1_listing = variant_1.channel_listings.get(channel=channel)
+    variant_2_listing = variant_2.channel_listings.get(channel=channel)
+    variant_3_listing = variant_3.channel_listings.get(channel=channel)
+
+    variant_1_price = Decimal(10)
+    variant_2_price = Decimal(25)
+    variant_3_price = Decimal(20)
+    variant_1_listing.price_amount = variant_1_price
+    variant_2_listing.price_amount = variant_2_price
+    variant_3_listing.price_amount = variant_3_price
+
+    ProductVariantChannelListing.objects.bulk_update(
+        [variant_1_listing, variant_2_listing, variant_3_listing], ["price_amount"]
+    )
+
+    voucher = voucher_specific_product_type
+    voucher.apply_once_per_order = True
+    voucher.save(update_fields=["apply_once_per_order"])
+
+    voucher_listing = voucher.channel_listings.get(channel=channel)
+    discount_value = 20
+    voucher_listing.discount_value = discount_value
+    voucher_listing.save(update_fields=["discount_value"])
+
+    voucher.variants.set([variant_2, variant_3])
+    voucher.products.clear()
+
+    expected_discount = Money(
+        variant_3_price * (Decimal(discount_value) / 100), checkout.currency
+    )
+
+    manager = get_plugins_manager()
+    lines, _ = fetch_checkout_lines(checkout)
+    checkout_info = fetch_checkout_info(checkout, lines, [], manager)
+    checkout.price_expiration = timezone.now()
+    subtotal_before_voucher = calculations.checkout_subtotal(
+        manager=manager,
+        checkout_info=checkout_info,
+        lines=lines,
+        address=checkout.shipping_address,
+        discounts=[],
+    )
+
+    variables = {
+        "id": to_global_id_or_none(checkout),
+        "promoCode": voucher.code,
+    }
+
+    # wheng
path: saleor/graphql/checkout/tests/mutations/test_checkout_add_promo_code.py
text: @user1:
```suggestion
    # when
```