Repository: saleor/saleor
Pull request: Added custom price to OrderLine.
I want to merge this change because it resolves https://github.com/saleor/saleor/issues/12777

The `OrderLineCreateInput` is extended with the optional price field `price`: PositiveDecimal, so the custom price could be set in the following mutation:
`DraftOrderCreate`
`orderLinesCreate`

<!-- Please mention all relevant issue numbers. -->

# Impact

* [ ] New migrations
* [x] 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. -->

* [x] Privileged queries and mutations are either absent or guarded by proper permission checks
* [x] Database queries are optimized and the number of queries is constant
* [x] Database migrations are either absent or optimized for zero downtime
* [x] The changes are covered by test cases


Problem statement:
Add Field price to the OrderLineCreateInput to add custom price to the order line
Right now in Saleor there is a mutation to add product to the checkout called checkouLinesAdd.
This mutation has argument called lines, in lines there is a possibility to add variants by their id and there is also possibility to add custom price of this item.
We have the case that we need to work on draft orders and we need to manipulate order line price like in the checkout.
Adding Field price to the OrderLineCreateInput to add custom price to the order line will be a solution for us.

Hints from issue discussion:
### 1. API changes
## Mutations
Add new field `price` to OrderLineCreateInput

```
input OrderLineCreateInput @doc(category: "Orders") {
  quantity: Int!
  variantId: ID!
  forceNewLine: Boolean = false
  price: PositiveDecimal
}

```
###  2.  Database changes
Add `price_override` to the `OrderLine` model

### 3. Todo List
- [ ] Add new filed `price` to the `OrderLineCreateInput` mutation
- [ ] Add `price_override` to the `OrderLine` model
- [ ] Use `price_override` as base price in `OrderLine`
- [ ] update the order calculation methods to use the `custom_price` and the resolvers for `OrderLine` type.
- [ ]  allow providing custom_price in `DraftOrderCreate` mutation

### 4. Tests
- [ ] Check how new field works with discounts

assign me for this issue
@KirillPlaksin, few remarks about the proposition:
-  you must also update the order calculation methods to use the `custom_price` and the resolvers for `OrderLine` type.
- I will also suggest adding a field `price_override` (instead of `custom_price`) on the `OrderLine` model to be consistent with the solution on `Checkout`
- you need to also allow providing `custom_price` in `DraftOrderCreate` mutation

Commit message: fix.

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83306e99fc6..3d6b367d07f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -48,6 +48,7 @@ All notable, unreleased changes to this project will be documented in this file.
 - Add `ORDER_REFUNDED`, `ORDER_FULLY_REFUNDED`, `ORDER_PAID` webhooks - #12533 by @korycins
 - Handle error raised by 0Auth when fetching token - #12672 by @IKarbowiakg
 - Fix adding new lines to draft order when existing line has deleted product - #12711 by @SzymJ
+- Add `price_override` to `OrderLine` for set custom price in mutation `OrderLineCreateInput` - #12855 by @KirillPlaksin
 
 # 3.13.0
 
diff --git a/saleor/graphql/order/mutations/draft_order_create.py b/saleor/graphql/order/mutations/draft_order_create.py
index b937cc7d047..2abf13c113d 100644
--- a/saleor/graphql/order/mutations/draft_order_create.py
+++ b/saleor/graphql/order/mutations/draft_order_create.py
@@ -65,6 +65,14 @@ class OrderLineCreateInput(OrderLineInput):
             "by skipping the matching logic. " + ADDED_IN_36
         ),
     )
+    price = PositiveDecimal(
+        required=False,
+        description=(
+            "Custom price of the item."
+            "When the line with the same variant "
+            "will be provided multiple times, the last price will be used."
+        ),
+    )
 
     class Meta:
         doc_category = DOC_CATEGORY_ORDERS
@@ -254,14 +262,20 @@ def clean_lines(cls, cleaned_input, lines, channel):
                 variant = list(
                     filter(lambda x: (x.pk == int(variant_db_id)), variants)
                 )[0]
+                custom_price = line.get("price", None)
 
                 if line.get("force_new_line"):
-                    line_data = OrderLineData(variant_id=variant_db_id, variant=variant)
+                    line_data = OrderLineData(
+                        variant_id=variant_db_id,
+                        variant=variant,
+                        price_override=custom_price,
+                    )
                     grouped_lines_data.append(line_data)
                 else:
                     line_data = lines_data_map[variant_db_id]
                     line_data.variant_id = variant_db_id
                     line_data.variant = variant
+                    line_data.price_override = custom_price
 
                 if (quantity := line.get("quantity")) is not None:
                     line_data.quantity += quantity
diff --git a/saleor/graphql/order/tests/mutations/test_draft_order_create.py b/saleor/graphql/order/tests/mutations/test_draft_order_create.py
index 0a4ec4e7847..06593e1cfb0 100644
--- a/saleor/graphql/order/tests/mutations/test_draft_order_create.py
+++ b/saleor/graphql/order/tests/mutations/test_draft_order_create.py
@@ -1417,3 +1417,65 @@ def test_draft_order_create_with_non_unique_external_reference(
     assert error["field"] == "externalReference"
     assert error["code"] == OrderErrorCode.UNIQUE.name
     assert error["message"] == "Order with this External reference already exists."
+
+
+def test_draft_order_create_with_custom_price_in_order_line(
+    staff_api_client,
+    permission_group_manage_orders,
+    customer_user,
+    product_without_shipping,
+    shipping_method,
+    variant,
+    graphql_address_data,
+    channel_USD,
+):
+    # given
+    variant_0 = variant
+    query = DRAFT_ORDER_CREATE_MUTATION
+    permission_group_manage_orders.user_set.add(staff_api_client.user)
+
+    user_id = graphene.Node.to_global_id("User", customer_user.id)
+    variant_0_id = graphene.Node.to_global_id("ProductVariant", variant_0.id)
+    channel_id = graphene.Node.to_global_id("Channel", channel_USD.id)
+    variant_1 = product_without_shipping.variants.first()
+    variant_1.quantity = 2
+    variant_1.save()
+    variant_1_id = graphene.Node.to_global_id("ProductVariant", variant_1.id)
+    expected_price_variant_0 = 10
+    expected_price_variant_1 = 20
+    variant_list = [
+        {"variantId": variant_0_id, "quantity": 2, "price": expected_price_variant_0},
+        {"variantId": variant_1_id, "quantity": 1, "price": expected_price_variant_1},
+    ]
+    shipping_address = graphql_address_data
+    shipping_id = graphene.Node.to_global_id("ShippingMethod", shipping_method.id)
+    variables = {
+        "input": {
+            "user": user_id,
+            "lines": variant_list,
+            "shippingAddress": shipping_address,
+            "shippingMethod": shipping_id,
+            "channelId": channel_id,
+        }
+    }
+
+    # when
+    response = staff_api_client.post_graphql(query, variables)
+    content = get_graphql_content(response)
+
+    # then
+    assert not content["data"]["draftOrderCreate"]["errors"]
+    data = content["data"]["draftOrderCreate"]["order"]
+    assert data["status"] == OrderStatus.DRAFT.upper()
+
+    order = Order.objects.first()
+
+    order_line_0 = order.lines.get(variant=variant_0)
+    assert order_line_0.price_override == expected_price_variant_0
+    assert order_line_0.base_unit_price_amount == expected_price_variant_0
+    assert order_line_0.undiscounted_base_unit_price_amount == expected_price_variant_0
+
+    order_line_1 = order.lines.get(variant=variant_1)
+    assert order_line_1.price_override == expected_price_variant_1
+    assert order_line_1.base_unit_price_amount == expected_price_variant_1
+    assert order_line_1.undiscounted_base_unit_price_amount == expected_price_variant_1
diff --git a/saleor/graphql/order/types.py b/saleor/graphql/order/types.py
index d3fdc567252..c899457ebeb 100644
--- a/saleor/graphql/order/types.py
+++ b/saleor/graphql/order/types.py
@@ -715,6 +715,11 @@ class OrderLine(ModelObjectType[models.OrderLine]):
             "permissions to access." + ADDED_IN_39
         ),
     )
+    price_override = graphene.Field(
+        PositiveDecimal,
+        description="Custom price of the order line.",
+        required=False,
+    )
 
     class Meta:
         description = "Represents order line of particular order."
diff --git a/saleor/graphql/order/utils.py b/saleor/graphql/order/utils.py
index 72b573d3e43..c3400916def 100644
--- a/saleor/graphql/order/utils.py
+++ b/saleor/graphql/order/utils.py
@@ -1,4 +1,5 @@
 from collections import defaultdict
+from decimal import Decimal
 from typing import TYPE_CHECKING, Dict, Iterable, List, Optional
 
 import graphene
@@ -27,6 +28,7 @@ class OrderLineData:
     variant_id: Optional[str] = None
     variant: Optional[ProductVariant] = None
     line_id: Optional[str] = None
+    price_override: Optional[Decimal] = None
     quantity: int = 0
 
 
diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index d8021368368..f63d2ac982a 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -10611,6 +10611,9 @@ type OrderLine implements Node & ObjectWithMetadata @doc(category: "Orders") {
   Added in Saleor 3.9.
   """
   taxClassPrivateMetadata: [MetadataItem!]!
+
+  """Custom price of the order line."""
+  priceOverride: PositiveDecimal
 }
 
 """
@@ -21638,6 +21641,11 @@ input OrderLineCreateInput @doc(category: "Orders") {
   Added in Saleor 3.6.
   """
   forceNewLine: Boolean = false
+
+  """
+  Custom price of the item.When the line with the same variant will be provided multiple times, the last price will be used.
+  """
+  price: PositiveDecimal
 }
 
 """
diff --git a/saleor/order/calculations.py b/saleor/order/calculations.py
index 36a9ba1c5cc..b045ceaeafb 100644
--- a/saleor/order/calculations.py
+++ b/saleor/order/calculations.py
@@ -88,7 +88,7 @@ def _update_order_discounts_and_base_undiscounted_total(
     )
 
 
-def _get_order_base_prices(order, lines):
+def _get_order_base_prices(order: Order, lines: Iterable[OrderLine]):
     currency = order.currency
     undiscounted_subtotal = zero_taxed_money(currency)
 
diff --git a/saleor/order/migrations/0165_orderline_price_override.py b/saleor/order/migrations/0165_orderline_price_override.py
new file mode 100644
index 00000000000..7946497b398
--- /dev/null
+++ b/saleor/order/migrations/0165_orderline_price_override.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.18 on 2023-05-15 11:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('order', '0164_auto_20230329_1200'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='orderline',
+            name='price_override',
+            field=models.DecimalField(blank=True, decimal_places=3, max_digits=12, null=True),
+        ),
+    ]
diff --git a/saleor/order/models.py b/saleor/order/models.py
index a27b55bb3cc..d37dc87d95e 100644
--- a/saleor/order/models.py
+++ b/saleor/order/models.py
@@ -15,6 +15,7 @@
 from django_measurement.models import MeasurementField
 from django_prices.models import MoneyField, TaxedMoneyField
 from measurement.measures import Weight
+from prices import Money
 
 from ..app.models import App
 from ..channel.models import Channel
@@ -666,6 +667,13 @@ class OrderLine(ModelWithMetadata):
     # Fulfilled when sale was applied to product in the line
     sale_id = models.CharField(max_length=255, null=True, blank=True)
 
+    price_override = models.DecimalField(
+        max_digits=settings.DEFAULT_MAX_DIGITS,
+        decimal_places=settings.DEFAULT_DECIMAL_PLACES,
+        blank=True,
+        null=True,
+    )
+
     objects = OrderLineManager()
 
     class Meta(ModelWithMetadata.Meta):
diff --git a/saleor/order/tests/test_order.py b/saleor/order/tests/test_order.py
index edbd9883768..42897d0d611 100644
--- a/saleor/order/tests/test_order.py
+++ b/saleor/order/tests/test_order.py
@@ -1339,3 +1339,110 @@ def test_order_update_charge_data_with_transaction_item_and_payment(
     assert order_with_lines.total_charged == Money(
         first_charged_amount + second_charged_amount, order_with_lines.currency
     )
+
+
+def test_add_variant_to_order_adds_line_for_new_variant_on_sale_with_custom_price(
+    order_with_lines,
+    product,
+    product_translation_fr,
+    sale,
+    discount_info,
+    settings,
+    anonymous_plugins,
+):
+    # given
+    order = order_with_lines
+    variant = product.variants.first()
+    discount_info.variants_ids.add(variant.id)
+    sale.variants.add(variant)
+    lines_before = order.lines.count()
+    settings.LANGUAGE_CODE = "fr"
+    price_override = Decimal(15)
+    line_data = OrderLineData(
+        variant_id=str(variant.id),
+        variant=variant,
+        quantity=1,
+        price_override=price_override,
+    )
+
+    # when
+    add_variant_to_order(
+        order=order,
+        line_data=line_data,
+        user=None,
+        app=None,
+        manager=anonymous_plugins,
+        discounts=[discount_info],
+    )
+
+    # then
+    line = order.lines.last()
+    variant_channel_listing = variant.channel_listings.get(channel=order.channel)
+    sale_channel_listing = sale.channel_listings.first()
+    assert order.lines.count() == lines_before + 1
+    assert line.price_override == price_override
+    assert line.product_sku == variant.sku
+    assert line.quantity == 1
+    assert variant_channel_listing.price_amount != price_override
+    unit_amount = price_override - sale_channel_listing.discount_value
+    assert line.unit_price == TaxedMoney(
+        net=Money(unit_amount, "USD"), gross=Money(unit_amount, "USD")
+    )
+    assert line.translated_product_name == str(variant.product.translated)
+    assert line.variant_name == str(variant)
+    assert line.product_name == str(variant.product)
+
+    assert line.unit_discount_amount == sale_channel_listing.discount_value
+    assert line.unit_discount_value == sale_channel_listing.discount_value
+    assert line.unit_discount_reason
+
+
+def test_add_variant_to_order_adds_line_with_custom_price_for_new_variant(
+    order_with_lines,
+    product,
+    product_translation_fr,
+    settings,
+    anonymous_plugins,
+):
+    # given
+    order = order_with_lines
+    variant = product.variants.get()
+    lines_before = order.lines.count()
+    settings.LANGUAGE_CODE = "fr"
+    price_override = Decimal(18)
+    line_data = OrderLineData(
+        variant_id=str(variant.id),
+        variant=variant,
+        quantity=1,
+        price_override=price_override,
+    )
+
+    # when
+    add_variant_to_order(
+        order=order,
+        line_data=line_data,
+        user=None,
+        app=None,
+        manager=anonymous_plugins,
+    )
+
+    # then
+    variant_channel_listing = variant.channel_listings.get(channel=order.channel)
+    line = order.lines.last()
+    assert order.lines.count() == lines_before + 1
+    assert line.price_override == price_override
+    assert variant_channel_listing.price_amount != price_override
+    assert line.product_sku == variant.sku
+    assert line.product_variant_id == variant.get_global_id()
+    assert line.quantity == 1
+    assert line.unit_price == TaxedMoney(
+        net=Money(price_override, "USD"), gross=Money(price_override, "USD")
+    )
+    assert line.translated_product_name == str(variant.product.translated)
+    assert line.variant_name == str(variant)
+    assert line.product_name == str(variant.product)
+    assert line.base_unit_price_amount == price_override
+    assert line.undiscounted_base_unit_price == price_override
+    assert not line.unit_discount_amount
+    assert not line.unit_discount_value
+    assert not line.unit_discount_reason
diff --git a/saleor/order/utils.py b/saleor/order/utils.py
index d718415c163..2374904b6c4 100644
--- a/saleor/order/utils.py
+++ b/saleor/order/utils.py
@@ -57,8 +57,10 @@
     from ..app.models import App
     from ..channel.models import Channel
     from ..checkout.fetch import CheckoutInfo
+    from ..discount import DiscountInfo
     from ..payment.models import Payment, TransactionItem
     from ..plugins.manager import PluginsManager
+    from ..graphql.order.utils import OrderLineData
 
 
 def get_order_country(order: Order) -> str:
@@ -206,15 +208,16 @@ def update_order_status(order: Order):
 
 @traced_atomic_transaction()
 def create_order_line(
-    order,
-    line_data,
-    manager,
-    discounts=None,
-    allocate_stock=False,
-):
+    order: Order,
+    line_data: "OrderLineData",
+    manager: "PluginsManager",
+    discounts: Optional[Iterable["DiscountInfo"]] = None,
+    allocate_stock: Optional[bool] = False,
+) -> OrderLine:
     channel = order.channel
     variant = line_data.variant
     quantity = line_data.quantity
+    price_override = line_data.price_override
 
     product = variant.product
     collections = product.collections.all()
@@ -222,13 +225,23 @@ def create_order_line(
 
     # vouchers are not applied for new lines in unconfirmed/draft orders
     untaxed_unit_price = variant.get_price(
-        product, collections, channel, channel_listing, discounts
+        product,
+        collections,
+        channel,
+        channel_listing,
+        discounts,
+        price_override=price_override,
     )
     if not discounts:
         untaxed_undiscounted_price = untaxed_unit_price
     else:
         untaxed_undiscounted_price = variant.get_price(
-            product, collections, channel, channel_listing, []
+            product,
+            collections,
+            channel,
+            channel_listing,
+            [],
+            price_override=price_override,
         )
     unit_price = TaxedMoney(net=untaxed_unit_price, gross=untaxed_unit_price)
     undiscounted_unit_price = TaxedMoney(
@@ -268,6 +281,7 @@ def create_order_line(
         total_price=total_price,
         undiscounted_total_price=undiscounted_total_price,
         variant=variant,
+        price_override=price_override,
         **get_tax_class_kwargs_for_order_line(tax_class),
     )
 


Code review comments:
diff_hunk: @@ -715,6 +715,11 @@ class OrderLine(ModelObjectType[models.OrderLine]):
             "permissions to access." + ADDED_IN_39
         ),
     )
+    price_override = graphene.Field(
path: saleor/graphql/order/types.py
text: @user1:
Do you need this? I don't see that we're providing the `price_override` in `CheckoutLine` type

@user2:
Agree. It should be only on `CheckoutLine` model

@author:
removed.