Repository: saleor/saleor

Issue:
What is the difference between a product's "purchaseCost" and its variants' "cost price"?
### What I'm trying to achieve

I am working with saleor release 2.8.0.

I am trying to build up my products list in a consistent way. In particular, I need a clear relationship between what a product cost me and how much it will cost my buyers.

In the GraphQL API, I can find the field `purchaseCost` in the `product` query. That field is protected by the `manage_products` permission. Is that `purchaseCost` the amount I paid as a vendor to get that product in my shop?  To be more specific, let's say I have an e-commerce platform grouping the products I get from some people. I want to gain money on those products, so that I sell them on my platform a bit more expensive than what those people sold them to me. Is the `purchaseCost` the price those people sold me their products? 

I can also find a field `costPrice` in the `variants` field of the `product` query. That field is not protected with the `manage_products` permission. I expect this field to be related to the product's `purchaseCost`. Therefore I expect that field to be protected by the same permission.

But I might've misunderstood the purpose of those fields. What is the difference between a product's `purchaseCost` and a its variant's `costPrice`?

My assumption until now is that `purchaseCost` has the same relationship to the variants' `costPrice` as the product `priceRange` has to the variants' `pricing`. If that holds true, however, I don't understand why the variants `costPrice` isn't protected with the `product.manage_products` permission. 



Discussion:
`purchaseCost` is a money range. The lowest purchase cost to the highest one of the product variants.

They should definitively be protected, yes. Thank you for the issue!

Resolving patch:
diff --git a/saleor/graphql/product/types/products.py b/saleor/graphql/product/types/products.py
--- a/saleor/graphql/product/types/products.py
+++ b/saleor/graphql/product/types/products.py
@@ -2,6 +2,7 @@
 
 import graphene
 import graphene_django_optimizer as gql_optimizer
+from django.conf import settings
 from django.db.models import Prefetch
 from graphene import relay
 from graphql.error import GraphQLError
@@ -205,6 +206,11 @@ class Meta:
 
 
 class ProductVariant(CountableDjangoObjectType, MetadataObjectType):
+    quantity = graphene.Int(
+        required=True,
+        description="Quantity of a product in the store's possession, "
+        "including the allocated stock that is waiting for shipment.",
+    )
     stock_quantity = graphene.Int(
         required=True, description="Quantity of a product available for sale."
     )
@@ -295,7 +301,6 @@ class Meta:
             "id",
             "name",
             "product",
-            "quantity",
             "quantity_allocated",
             "sku",
             "track_inventory",
@@ -310,8 +315,9 @@ def resolve_digital_content(root: models.ProductVariant, *_args):
         return getattr(root, "digital_content", None)
 
     @staticmethod
-    def resolve_stock_quantity(root: models.ProductVariant, *_args):
-        return root.quantity_available
+    def resolve_stock_quantity(root: models.ProductVariant, _info):
+        exact_quantity_available = root.quantity_available
+        return min(exact_quantity_available, settings.MAX_CHECKOUT_LINE_QUANTITY)
 
     @staticmethod
     @gql_optimizer.resolver_hints(
@@ -325,6 +331,11 @@ def resolve_attributes(root: models.ProductVariant, info):
     def resolve_margin(root: models.ProductVariant, *_args):
         return get_margin_for_variant(root)
 
+    @staticmethod
+    @permission_required("product.manage_products")
+    def resolve_cost_price(root: models.ProductVariant, *_args):
+        return root.cost_price
+
     @staticmethod
     def resolve_price(root: models.ProductVariant, *_args):
         return (
diff --git a/saleor/product/models.py b/saleor/product/models.py
--- a/saleor/product/models.py
+++ b/saleor/product/models.py
@@ -470,7 +470,7 @@ def is_visible(self):
 
     @property
     def is_available(self):
-        return self.product.is_available
+        return self.is_visible and self.is_in_stock()
 
     def check_quantity(self, quantity):
         """Check if there is at least the given quantity in stock.


Test patch:
diff --git a/tests/api/benchmark/test_checkout.py b/tests/api/benchmark/test_checkout.py
--- a/tests/api/benchmark/test_checkout.py
+++ b/tests/api/benchmark/test_checkout.py
@@ -100,7 +100,6 @@ def test_create_checkout(api_client, graphql_address_data, variant, count_querie
             ...Price
           }
           variant {
-            stockQuantity
             ...ProductVariant
           }
           quantity
@@ -240,7 +239,6 @@ def test_add_shipping_to_checkout(
             ...Price
           }
           variant {
-            stockQuantity
             ...ProductVariant
           }
           quantity
@@ -373,7 +371,6 @@ def test_add_billing_address_to_checkout(
             ...Price
           }
           variant {
-            stockQuantity
             ...ProductVariant
           }
           quantity
diff --git a/tests/api/test_product.py b/tests/api/test_product.py
--- a/tests/api/test_product.py
+++ b/tests/api/test_product.py
@@ -147,7 +147,6 @@ def test_product_query(staff_api_client, product, permission_manage_products):
                         }
                         variants {
                             name
-                            stockQuantity
                         }
                         isAvailable
                         pricing {
@@ -2306,98 +2305,140 @@ def test_report_product_sales(
     assert Decimal(amount) == line_b.quantity * line_b.unit_price_gross_amount
 
 
-def test_variant_revenue_permissions(
-    staff_api_client, permission_manage_products, permission_manage_orders, product
+@pytest.mark.parametrize(
+    "field, is_nested",
+    (
+        ("basePrice", True),
+        ("purchaseCost", True),
+        ("margin", True),
+        ("privateMeta", True),
+    ),
+)
+def test_product_restricted_fields_permissions(
+    staff_api_client,
+    permission_manage_products,
+    permission_manage_orders,
+    product,
+    field,
+    is_nested,
 ):
+    """Ensure non-public (restricted) fields are correctly requiring
+    the 'manage_products' permission.
+    """
     query = """
-    query VariantRevenue($id: ID!) {
-        productVariant(id: $id) {
-            revenue(period: TODAY) {
-                gross {
-                    localized
-                }
-            }
+    query Product($id: ID!) {
+        product(id: $id) {
+            %(field)s
         }
     }
-    """
-    variant = product.variants.first()
-    variables = {"id": graphene.Node.to_global_id("ProductVariant", variant.pk)}
+    """ % {
+        "field": field if not is_nested else "%s { __typename }" % field
+    }
+    variables = {"id": graphene.Node.to_global_id("Product", product.pk)}
     permissions = [permission_manage_orders, permission_manage_products]
     response = staff_api_client.post_graphql(query, variables, permissions)
     content = get_graphql_content(response)
-    assert content["data"]["productVariant"]["revenue"]
+    assert field in content["data"]["product"]
 
 
-def test_variant_quantity_permissions(
-    staff_api_client, permission_manage_products, product
+@pytest.mark.parametrize(
+    "field, is_nested",
+    (
+        ("digitalContent", True),
+        ("margin", False),
+        ("costPrice", True),
+        ("priceOverride", True),
+        ("quantity", False),
+        ("quantityOrdered", False),
+        ("quantityAllocated", False),
+        ("privateMeta", True),
+    ),
+)
+def test_variant_restricted_fields_permissions(
+    staff_api_client,
+    permission_manage_products,
+    permission_manage_orders,
+    product,
+    field,
+    is_nested,
 ):
+    """Ensure non-public (restricted) fields are correctly requiring
+    the 'manage_products' permission.
+    """
     query = """
-    query Quantity($id: ID!) {
+    query ProductVariant($id: ID!) {
         productVariant(id: $id) {
-            quantity
+            %(field)s
         }
     }
-    """
+    """ % {
+        "field": field if not is_nested else "%s { __typename }" % field
+    }
     variant = product.variants.first()
     variables = {"id": graphene.Node.to_global_id("ProductVariant", variant.pk)}
-    permissions = [permission_manage_products]
+    permissions = [permission_manage_orders, permission_manage_products]
     response = staff_api_client.post_graphql(query, variables, permissions)
     content = get_graphql_content(response)
-    assert "quantity" in content["data"]["productVariant"]
+    assert field in content["data"]["productVariant"]
 
 
-def test_variant_quantity_ordered_permissions(
-    staff_api_client, permission_manage_products, permission_manage_orders, product
-):
-    query = """
-    query QuantityOrdered($id: ID!) {
+VARIANT_QUANTITY_AVAILABLE_IN_STOCK_QUERY = """
+    query ProductVariant($id: ID!) {
         productVariant(id: $id) {
-            quantityOrdered
+            stockQuantity
         }
     }
     """
-    variant = product.variants.first()
-    variables = {"id": graphene.Node.to_global_id("ProductVariant", variant.pk)}
-    permissions = [permission_manage_orders, permission_manage_products]
-    response = staff_api_client.post_graphql(query, variables, permissions)
-    content = get_graphql_content(response)
-    assert "quantityOrdered" in content["data"]["productVariant"]
 
 
-def test_variant_quantity_allocated_permissions(
-    staff_api_client, permission_manage_products, permission_manage_orders, product
+def test_variant_available_stock_quantity_is_capped_for_authorized_user(
+    staff_api_client, permission_manage_products, variant, settings
 ):
-    query = """
-    query QuantityAllocated($id: ID!) {
-        productVariant(id: $id) {
-            quantityAllocated
-        }
-    }
     """
-    variant = product.variants.first()
+    The exact quantity available in stock should be accessible for a staff
+    user having the permission to manage products.
+    """
+    actual_stock_available = 60
+    expected_stock_available = settings.MAX_CHECKOUT_LINE_QUANTITY = 50
+
+    variant.quantity = actual_stock_available
+    variant.quantity_allocated = 0
+    variant.save(update_fields=["quantity", "quantity_allocated"])
+
+    query = VARIANT_QUANTITY_AVAILABLE_IN_STOCK_QUERY
     variables = {"id": graphene.Node.to_global_id("ProductVariant", variant.pk)}
-    permissions = [permission_manage_orders, permission_manage_products]
-    response = staff_api_client.post_graphql(query, variables, permissions)
-    content = get_graphql_content(response)
-    assert "quantityAllocated" in content["data"]["productVariant"]
+    staff_api_client.user.user_permissions.add(permission_manage_products)
+
+    data = get_graphql_content(staff_api_client.post_graphql(query, variables))
+    stock_available = data["data"]["productVariant"]["stockQuantity"]
 
+    assert stock_available == expected_stock_available
 
-def test_variant_margin_permissions(
-    staff_api_client, permission_manage_products, permission_manage_orders, product
+
+@pytest.mark.parametrize(
+    "actual_stock_available, expected_stock_available",
+    ((60, 50), (50, 50), (49, 49), (0, 0)),
+)
+def test_variant_available_stock_quantity_is_capped_for_unauthorized_user(
+    api_client, variant, settings, actual_stock_available, expected_stock_available
 ):
-    query = """
-    query Margin($id: ID!) {
-        productVariant(id: $id) {
-            margin
-        }
-    }
     """
-    variant = product.variants.first()
+    The exact quantity available in stock shouldn't be made available to customers
+    and unauthorized staff users. Instead it should be capped to a said value.
+    """
+    settings.MAX_CHECKOUT_LINE_QUANTITY = 50
+
+    variant.quantity = actual_stock_available
+    variant.quantity_allocated = 0
+    variant.save(update_fields=["quantity", "quantity_allocated"])
+
+    query = VARIANT_QUANTITY_AVAILABLE_IN_STOCK_QUERY
     variables = {"id": graphene.Node.to_global_id("ProductVariant", variant.pk)}
-    permissions = [permission_manage_orders, permission_manage_products]
-    response = staff_api_client.post_graphql(query, variables, permissions)
-    content = get_graphql_content(response)
-    assert "margin" in content["data"]["productVariant"]
+
+    data = get_graphql_content(api_client.post_graphql(query, variables))
+    stock_available = data["data"]["productVariant"]["stockQuantity"]
+
+    assert stock_available == expected_stock_available
 
 
 def test_variant_digital_content(
diff --git a/tests/test_product_availability.py b/tests/test_product_availability.py
--- a/tests/test_product_availability.py
+++ b/tests/test_product_availability.py
@@ -1,6 +1,7 @@
 import datetime
 from unittest.mock import Mock
 
+import pytest
 from prices import Money, TaxedMoney, TaxedMoneyRange
 
 from saleor.extensions.manager import ExtensionsManager
@@ -57,19 +58,52 @@ def test_product_availability_status(unavailable_product):
     assert status == ProductAvailabilityStatus.NOT_YET_AVAILABLE
 
 
-def test_variant_availability_status(unavailable_product):
+def test_variant_is_out_of_stock_when_product_is_unavalable(unavailable_product):
     product = unavailable_product
     product.product_type.has_variants = True
 
     variant = product.variants.create(sku="test")
     variant.quantity = 0
-    variant.save()
+    variant.save(update_fields=["quantity"])
+
     status = get_variant_availability_status(variant)
     assert status == VariantAvailabilityStatus.OUT_OF_STOCK
 
-    variant.quantity = 5
-    variant.save()
-    get_variant_availability_status(variant)
+
+@pytest.mark.parametrize(
+    "stock, expected_status",
+    (
+        (0, VariantAvailabilityStatus.OUT_OF_STOCK),
+        (1, VariantAvailabilityStatus.AVAILABLE),
+    ),
+)
+def test_variant_availability_status(variant, stock, expected_status):
+    variant.quantity = stock
+    variant.quantity_allocated = 0
+
+    status = get_variant_availability_status(variant)
+    assert status == expected_status
+
+
+def test_variant_is_still_available_when_another_variant_is_unavailable(
+    product_variant_list
+):
+    """
+    Ensure a variant is not incorrectly flagged as out of stock when another variant
+    from the parent product is unavailable.
+    """
+
+    unavailable_variant, available_variant = product_variant_list[:2]
+
+    unavailable_variant.quantity = 0
+    available_variant.quantity = 1
+    available_variant.quantity_allocated = 0
+
+    status = get_variant_availability_status(available_variant)
+    assert status == VariantAvailabilityStatus.AVAILABLE
+
+    status = get_variant_availability_status(unavailable_variant)
+    assert status == VariantAvailabilityStatus.OUT_OF_STOCK
 
 
 def test_availability(product, monkeypatch, settings):