Repository: saleor/saleor

Issue:
Bug: Invalid `undiscountedTotal` value on `Order`
### What are you trying to achieve?

The `undiscountedTotal` on `Order` type should return the price before discounts (sales and vouchers). Right now, when the voucher is applied to specific products, the `undiscountedTotal` is the same as `total`, the discount is visible only on `lines` level.

### Steps to reproduce the problem

1. Create vocher with specific products
2. Create checkout with the product that is included in the voucher
3. Use voucher
4. Complete checkout
5. Check the `order` data:

```graphql
query {
  order(id: "T3JkZXI6MmQ3YjRhYWItZDU0Ni00NTJmLTgzMGQtNDM5ZGMxOTc2NGQx") {
    id
    number
    total {
      gross {
        amount
      }
      net {
        amount
      }
    }
    undiscountedTotal {
      gross {
        amount
      }
      net {
	amount
      }
    }
    voucher {
      discountValue
      discountValueType
      type
    }
    lines {
     quantity
      productName
      undiscountedUnitPrice {
        gross {
          amount
        }
      }
      unitPrice {
        gross {
          amount
        }
      }
    }
  }
}
```

### What did you expect to happen?

The `undiscountedTotal` on `Order` returns the price before discounts (sales and vouchers).

### Logs

_No response_

### Environment

Saleor version: 3.4+



Discussion:

Resolving patch:
diff --git a/saleor/checkout/complete_checkout.py b/saleor/checkout/complete_checkout.py
--- a/saleor/checkout/complete_checkout.py
+++ b/saleor/checkout/complete_checkout.py
@@ -193,13 +193,16 @@ def _create_line_for_order(
     if translated_variant_name == variant_name:
         translated_variant_name = ""
 
+    # the price with sale discount - base price that is used for total price calculation
     base_unit_price = calculate_base_line_unit_price(
         line_info=checkout_line_info, channel=checkout_info.channel, discounts=discounts
     )
+    # the unit price before applying any discount (sale or voucher)
     undiscounted_base_unit_price = calculate_undiscounted_base_line_unit_price(
         line_info=checkout_line_info,
         channel=checkout_info.channel,
     )
+    # the total price before applying any discount (sale or voucher)
     undiscounted_base_total_price = calculate_undiscounted_base_line_total_price(
         line_info=checkout_line_info,
         channel=checkout_info.channel,
@@ -210,6 +213,7 @@ def _create_line_for_order(
     undiscounted_total_price = TaxedMoney(
         net=undiscounted_base_total_price, gross=undiscounted_base_total_price
     )
+    # total price after applying all discounts - sales and vouchers
     total_line_price = calculations.checkout_line_total(
         manager=manager,
         checkout_info=checkout_info,
@@ -217,6 +221,7 @@ def _create_line_for_order(
         checkout_line_info=checkout_line_info,
         discounts=discounts,
     )
+    # unit price after applying all discounts - sales and vouchers
     unit_price = calculations.checkout_line_unit_price(
         manager=manager,
         checkout_info=checkout_info,
@@ -359,7 +364,6 @@ def _create_lines_for_order(
         replace=True,
         check_reservations=True,
     )
-
     return [
         _create_line_for_order(
             manager,
@@ -400,6 +404,7 @@ def _prepare_order_data(
         address=address,
         discounts=discounts,
     )
+    # checkout.discount contains only discounts applied on entire order
     undiscounted_total = taxed_total + checkout.discount
 
     base_shipping_price = base_checkout_delivery_price(checkout_info, lines)
@@ -423,6 +428,24 @@ def _prepare_order_data(
         )
     )
     order_data.update(_process_user_data_for_order(checkout_info, manager))
+
+    order_data["lines"] = _create_lines_for_order(
+        manager,
+        checkout_info,
+        lines,
+        discounts,
+        taxes_included_in_prices,
+    )
+    # Calculate the discount that was applied for each lines - the sales and voucher
+    # discounts on specific products are included on the line level and are not included
+    # in the checkout.discount amount.
+    line_discounts = zero_taxed_money(checkout.currency)
+    for line in order_data["lines"]:
+        line_discounts += line.line.undiscounted_total_price - line.line.total_price
+
+    # include discounts applied on lines, in order undiscounted total price
+    undiscounted_total += line_discounts
+
     order_data.update(
         {
             "language_code": checkout.language_code,
@@ -433,14 +456,6 @@ def _prepare_order_data(
         }
     )
 
-    order_data["lines"] = _create_lines_for_order(
-        manager,
-        checkout_info,
-        lines,
-        discounts,
-        taxes_included_in_prices,
-    )
-
     # validate checkout gift cards
     _validate_gift_cards(checkout)
 


Test patch:
diff --git a/saleor/graphql/checkout/tests/mutations/test_checkout_complete.py b/saleor/graphql/checkout/tests/mutations/test_checkout_complete.py
--- a/saleor/graphql/checkout/tests/mutations/test_checkout_complete.py
+++ b/saleor/graphql/checkout/tests/mutations/test_checkout_complete.py
@@ -66,6 +66,15 @@
                         amount
                     }
                 }
+                undiscountedTotal {
+                    currency
+                    net {
+                        amount
+                    }
+                    gross {
+                        amount
+                    }
+                }
             }
             errors {
                 field,
@@ -815,6 +824,8 @@ def test_checkout_with_voucher_complete(
     checkout.store_value_in_private_metadata(items={"accepted": "false"})
     checkout.save()
 
+    discount_amount = checkout.discount
+
     checkout_line = checkout.lines.first()
     checkout_line_quantity = checkout_line.quantity
     checkout_line_variant = checkout_line.variant
@@ -855,6 +866,9 @@ def test_checkout_with_voucher_complete(
     assert order.metadata == checkout.metadata
     assert order.private_metadata == checkout.private_metadata
 
+    assert order.total == total
+    assert order.undiscounted_total == total + discount_amount
+
     order_line = order.lines.first()
     assert checkout_line_quantity == order_line.quantity
     assert checkout_line_variant == order_line.variant
@@ -873,6 +887,375 @@ def test_checkout_with_voucher_complete(
     ).exists(), "Checkout should have been deleted"
 
 
+def test_checkout_with_voucher_complete_product_on_sale(
+    user_api_client,
+    checkout_with_voucher_percentage,
+    voucher_percentage,
+    discount_info,
+    sale,
+    payment_dummy,
+    address,
+    shipping_method,
+):
+    # given
+    voucher_used_count = voucher_percentage.used
+    voucher_percentage.usage_limit = voucher_used_count + 1
+    voucher_percentage.save(update_fields=["usage_limit"])
+
+    checkout = checkout_with_voucher_percentage
+    checkout.shipping_address = address
+    checkout.shipping_method = shipping_method
+    checkout.billing_address = address
+    checkout.store_value_in_metadata(items={"accepted": "true"})
+    checkout.store_value_in_private_metadata(items={"accepted": "false"})
+    checkout.save()
+
+    discount_amount = checkout.discount
+
+    checkout_line = checkout.lines.first()
+    checkout_line_quantity = checkout_line.quantity
+    checkout_line_variant = checkout_line.variant
+
+    discount_info.variants_ids.add(checkout_line_variant.id)
+    sale.variants.add(checkout_line_variant)
+
+    manager = get_plugins_manager()
+    lines, _ = fetch_checkout_lines(checkout)
+    checkout_info = fetch_checkout_info(checkout, lines, [], manager)
+
+    total = calculations.checkout_total(
+        manager=manager,
+        checkout_info=checkout_info,
+        lines=lines,
+        address=address,
+        discounts=[discount_info],
+    )
+    payment = payment_dummy
+    payment.is_active = True
+    payment.order = None
+    payment.total = total.gross.amount
+    payment.currency = total.gross.currency
+    payment.checkout = checkout
+    payment.save()
+    assert not payment.transactions.exists()
+
+    orders_count = Order.objects.count()
+    variables = {
+        "id": to_global_id_or_none(checkout),
+        "redirectUrl": "https://www.example.com",
+    }
+
+    # when
+    response = user_api_client.post_graphql(MUTATION_CHECKOUT_COMPLETE, variables)
+
+    # then
+    content = get_graphql_content(response)
+    data = content["data"]["checkoutComplete"]
+    assert not data["errors"]
+
+    order_token = data["order"]["token"]
+    order_id = data["order"]["id"]
+    assert Order.objects.count() == orders_count + 1
+    order = Order.objects.first()
+    assert str(order.id) == order_token
+    assert order_id == graphene.Node.to_global_id("Order", order.id)
+    assert order.metadata == checkout.metadata
+    assert order.private_metadata == checkout.private_metadata
+
+    order_line = order.lines.first()
+    assert order.total == total
+    assert order.undiscounted_total == total + discount_amount + (
+        order_line.undiscounted_total_price - order_line.total_price
+    )
+
+    assert checkout_line_quantity == order_line.quantity
+    assert checkout_line_variant == order_line.variant
+    assert order.shipping_address == address
+    assert order.shipping_method == checkout.shipping_method
+    assert order.payments.exists()
+    order_payment = order.payments.first()
+    assert order_payment == payment
+    assert payment.transactions.count() == 1
+
+    voucher_percentage.refresh_from_db()
+    assert voucher_percentage.used == voucher_used_count + 1
+
+    assert not Checkout.objects.filter(
+        pk=checkout.pk
+    ).exists(), "Checkout should have been deleted"
+
+
+def test_checkout_with_voucher_on_specific_product_complete(
+    user_api_client,
+    checkout_with_item_and_voucher_specific_products,
+    voucher_specific_product_type,
+    payment_dummy,
+    address,
+    shipping_method,
+):
+    # given
+    voucher_used_count = voucher_specific_product_type.used
+    voucher_specific_product_type.usage_limit = voucher_used_count + 1
+    voucher_specific_product_type.save(update_fields=["usage_limit"])
+
+    checkout = checkout_with_item_and_voucher_specific_products
+    checkout.shipping_address = address
+    checkout.shipping_method = shipping_method
+    checkout.billing_address = address
+    checkout.store_value_in_metadata(items={"accepted": "true"})
+    checkout.store_value_in_private_metadata(items={"accepted": "false"})
+    checkout.save()
+
+    checkout_line = checkout.lines.first()
+    checkout_line_quantity = checkout_line.quantity
+    checkout_line_variant = checkout_line.variant
+
+    manager = get_plugins_manager()
+    lines, _ = fetch_checkout_lines(checkout)
+    checkout_info = fetch_checkout_info(checkout, lines, [], manager)
+
+    total = calculations.checkout_total(
+        manager=manager, checkout_info=checkout_info, lines=lines, address=address
+    )
+    payment = payment_dummy
+    payment.is_active = True
+    payment.order = None
+    payment.total = total.gross.amount
+    payment.currency = total.gross.currency
+    payment.checkout = checkout
+    payment.save()
+    assert not payment.transactions.exists()
+
+    orders_count = Order.objects.count()
+    variables = {
+        "id": to_global_id_or_none(checkout),
+        "redirectUrl": "https://www.example.com",
+    }
+
+    # when
+    response = user_api_client.post_graphql(MUTATION_CHECKOUT_COMPLETE, variables)
+
+    # then
+    content = get_graphql_content(response)
+    data = content["data"]["checkoutComplete"]
+    assert not data["errors"]
+
+    order_token = data["order"]["token"]
+    order_id = data["order"]["id"]
+    assert Order.objects.count() == orders_count + 1
+    order = Order.objects.first()
+    assert str(order.id) == order_token
+    assert order_id == graphene.Node.to_global_id("Order", order.id)
+    assert order.metadata == checkout.metadata
+    assert order.private_metadata == checkout.private_metadata
+
+    order_line = order.lines.first()
+    assert order.total == total
+    assert order.undiscounted_total == total + (
+        order_line.undiscounted_total_price - order_line.total_price
+    )
+
+    assert checkout_line_quantity == order_line.quantity
+    assert checkout_line_variant == order_line.variant
+    assert order.shipping_address == address
+    assert order.shipping_method == checkout.shipping_method
+    assert order.payments.exists()
+    order_payment = order.payments.first()
+    assert order_payment == payment
+    assert payment.transactions.count() == 1
+
+    voucher_specific_product_type.refresh_from_db()
+    assert voucher_specific_product_type.used == voucher_used_count + 1
+
+    assert not Checkout.objects.filter(
+        pk=checkout.pk
+    ).exists(), "Checkout should have been deleted"
+
+
+def test_checkout_complete_product_on_sale(
+    user_api_client,
+    checkout_with_item,
+    discount_info,
+    sale,
+    payment_dummy,
+    address,
+    shipping_method,
+):
+    # given
+    checkout = checkout_with_item
+    checkout.shipping_address = address
+    checkout.shipping_method = shipping_method
+    checkout.billing_address = address
+    checkout.store_value_in_metadata(items={"accepted": "true"})
+    checkout.store_value_in_private_metadata(items={"accepted": "false"})
+    checkout.save()
+
+    checkout_line = checkout.lines.first()
+    checkout_line_quantity = checkout_line.quantity
+    checkout_line_variant = checkout_line.variant
+
+    discount_info.variants_ids.add(checkout_line_variant.id)
+    sale.variants.add(checkout_line_variant)
+
+    manager = get_plugins_manager()
+    lines, _ = fetch_checkout_lines(checkout)
+    checkout_info = fetch_checkout_info(checkout, lines, discount_info, manager)
+
+    total = calculations.checkout_total(
+        manager=manager,
+        checkout_info=checkout_info,
+        lines=lines,
+        address=address,
+        discounts=[discount_info],
+    )
+    payment = payment_dummy
+    payment.is_active = True
+    payment.order = None
+    payment.total = total.gross.amount
+    payment.currency = total.gross.currency
+    payment.checkout = checkout
+    payment.save()
+    assert not payment.transactions.exists()
+
+    orders_count = Order.objects.count()
+    variables = {
+        "id": to_global_id_or_none(checkout),
+        "redirectUrl": "https://www.example.com",
+    }
+
+    # when
+    response = user_api_client.post_graphql(MUTATION_CHECKOUT_COMPLETE, variables)
+
+    # then
+    content = get_graphql_content(response)
+    data = content["data"]["checkoutComplete"]
+    assert not data["errors"]
+
+    order_token = data["order"]["token"]
+    order_id = data["order"]["id"]
+    assert Order.objects.count() == orders_count + 1
+    order = Order.objects.first()
+    assert str(order.id) == order_token
+    assert order_id == graphene.Node.to_global_id("Order", order.id)
+    assert order.metadata == checkout.metadata
+    assert order.private_metadata == checkout.private_metadata
+
+    order_line = order.lines.first()
+    assert order.total == total
+    assert order.undiscounted_total == total + (
+        order_line.undiscounted_total_price - order_line.total_price
+    )
+
+    assert checkout_line_quantity == order_line.quantity
+    assert checkout_line_variant == order_line.variant
+    assert order.shipping_address == address
+    assert order.shipping_method == checkout.shipping_method
+    assert order.payments.exists()
+    order_payment = order.payments.first()
+    assert order_payment == payment
+    assert payment.transactions.count() == 1
+
+    assert not Checkout.objects.filter(
+        pk=checkout.pk
+    ).exists(), "Checkout should have been deleted"
+
+
+def test_checkout_with_voucher_on_specific_product_complete_with_product_on_sale(
+    user_api_client,
+    checkout_with_item_and_voucher_specific_products,
+    voucher_specific_product_type,
+    discount_info,
+    sale,
+    payment_dummy,
+    address,
+    shipping_method,
+):
+    # given
+    voucher_used_count = voucher_specific_product_type.used
+    voucher_specific_product_type.usage_limit = voucher_used_count + 1
+    voucher_specific_product_type.save(update_fields=["usage_limit"])
+
+    checkout = checkout_with_item_and_voucher_specific_products
+    checkout.shipping_address = address
+    checkout.shipping_method = shipping_method
+    checkout.billing_address = address
+    checkout.store_value_in_metadata(items={"accepted": "true"})
+    checkout.store_value_in_private_metadata(items={"accepted": "false"})
+    checkout.save()
+
+    checkout_line = checkout.lines.first()
+    checkout_line_quantity = checkout_line.quantity
+    checkout_line_variant = checkout_line.variant
+
+    discount_info.variants_ids.add(checkout_line_variant.id)
+    sale.variants.add(checkout_line_variant)
+
+    manager = get_plugins_manager()
+    lines, _ = fetch_checkout_lines(checkout)
+    checkout_info = fetch_checkout_info(checkout, lines, discount_info, manager)
+
+    total = calculations.checkout_total(
+        manager=manager,
+        checkout_info=checkout_info,
+        lines=lines,
+        address=address,
+        discounts=[discount_info],
+    )
+    payment = payment_dummy
+    payment.is_active = True
+    payment.order = None
+    payment.total = total.gross.amount
+    payment.currency = total.gross.currency
+    payment.checkout = checkout
+    payment.save()
+    assert not payment.transactions.exists()
+
+    orders_count = Order.objects.count()
+    variables = {
+        "id": to_global_id_or_none(checkout),
+        "redirectUrl": "https://www.example.com",
+    }
+
+    # when
+    response = user_api_client.post_graphql(MUTATION_CHECKOUT_COMPLETE, variables)
+
+    # then
+    content = get_graphql_content(response)
+    data = content["data"]["checkoutComplete"]
+    assert not data["errors"]
+
+    order_token = data["order"]["token"]
+    order_id = data["order"]["id"]
+    assert Order.objects.count() == orders_count + 1
+    order = Order.objects.first()
+    assert str(order.id) == order_token
+    assert order_id == graphene.Node.to_global_id("Order", order.id)
+    assert order.metadata == checkout.metadata
+    assert order.private_metadata == checkout.private_metadata
+
+    order_line = order.lines.first()
+    assert order.total == total
+    assert order.undiscounted_total == total + (
+        order_line.undiscounted_total_price - order_line.total_price
+    )
+
+    assert checkout_line_quantity == order_line.quantity
+    assert checkout_line_variant == order_line.variant
+    assert order.shipping_address == address
+    assert order.shipping_method == checkout.shipping_method
+    assert order.payments.exists()
+    order_payment = order.payments.first()
+    assert order_payment == payment
+    assert payment.transactions.count() == 1
+
+    voucher_specific_product_type.refresh_from_db()
+    assert voucher_specific_product_type.used == voucher_used_count + 1
+
+    assert not Checkout.objects.filter(
+        pk=checkout.pk
+    ).exists(), "Checkout should have been deleted"
+
+
 @patch.object(PluginsManager, "preprocess_order_creation")
 @pytest.mark.integration
 def test_checkout_with_voucher_not_increase_uses_on_preprocess_order_creation_failure(