Repository: saleor/saleor
Pull request: Implement variant availability, introducing discounts in variants
Fixes #3765. Proposed solution is to add a `availability` field of type `VariantAvailability` based on `ProductAvailability` for the `ProductType` variant as both variants and products have the same availability data fields, except price ranges.

### Changelist
- [x] Refactor `get_availability` to `get_product_availability`;
- [x] Implement `variants.availability`;
- [x] Write tests;
- [x] Write test for the local currency case;
- [x] Add missing gql descriptions;
- [x] Move availability to parent type;
- [x] Update the gql schema;
- [x] Ensure it doesn't break the storefront 2.0.;

### Pull Request Checklist

<!-- Please keep this section. It will make maintainer's life easier. -->

1. [x] Privileged views and APIs are guarded by proper permission checks.
1. [x] All visible strings are translated with proper context.
1. [x] All data-formatting is locale-aware (dates, numbers, and so on).
1. [x] Database queries are optimized and the number of queries is constant.
1. [x] Database migration files are up to date.
1. [x] The changes are tested.
1. [x] The code is documented (docstrings, project documentation).
1. [x] GraphQL schema and type definitions are up to date.
1. [x] Changes are mentioned in the changelog.


Problem statement:
Add Discounted Price Field on GraphQL Products
### What I'm trying to achieve
It is normal to display the discounted price for a product on any listings. Ideally, we should have both the original price and the discounted price so we can show them side-by-side to the user. This is currently not available when the product is retrieved from graphql.

### Describe a proposed solution
Add a sale price field that holds the discounted price on the product representation in graphql. Additionally, having a list of the vouchers applicable to that product would be very useful to further entice shoppers.

### Other solutions I've tried and won't work
A roundabout way would be to request all the sales, all the vouchers, and then manually try to figure out if any of them apply to the product. When listing hundreds of products, this is just not efficient or practical.

Hints from issue discussion:
What you are looking for is the `availability` field in the `Product` type. This object provides information about the pricing of a product such as a price including/excluding discount, discount amount, information whether a product is on sale etc.:

```
product(id: "UHJvZHVjdDo2NA==") {
  name
  availability {
    available
    onSale
    discount {
      ...Price
    }
    priceRange {
      start {
        ...Price
      }
      stop {
        ...Price
      }
    }
    priceRangeUndiscounted {
      start {
        ...Price
      }
      stop {
        ...Price
      }
    }
  }
}
```
 
However, we should reiterate on that, as there is little consistency between fields available at the product and variants level. Few problems that I see:
- `ProductVariant` type returns only `price` field which doesn't include discounts. We should provide ways to return the base price (before any discounts are applied) and the actual price that customers see.
- Product price can be accessed through two different fields (that both act differently, which is confusing):
  - `availability` field - with discounts and other useful information about pricing
  - price` field - base price without discounts
- `ProductAvailability` type lacks descriptions

I think we should take a look at this part of the schema and think of a new, unified approach.
Thank you for the detailed explanation. I'd indeed overlooked `availability`. As someone with no commerce background, could you further explain to me the `gross` and `net` prices in `availability`? I'm guessing the amount to be paid by the customer would include taxes, so I should display `gross` discounted price, but I'm not certain of this.

Those problems listed are all valid and important. Some documentation on how discounts work would also be nice. For example, how are they calculated? How are multiple sales affecting a single product resolved? I'm sure they won't stack... but having these clearly described in the docs would be really helpful to developers with no commerce background.
@khalibloo The difference between `gross` and `net` is as you said - `gross` prices include taxes while `net` don't. Whether to display gross or net prices to your customers vary between countries the shops are based in. As far as I know, some countries require to include taxes, e.g. in Europe, you have to use gross prices, while in the US not necessarily (but again - I'm not an expert in this field, and you'd have to read about it more yourself).

About the docs - I know it's not perfect now but we've just started putting together an extensive guide for our dashboard which will also cover such concepts like discounts, sales and how they work.
Proposed solution is to add a `availability` field of type `VariantAvailability` based on `ProductAvailability` for the `ProductType` variant as both variants and products have the same availability data fields, except price ranges.

Commit message: Restore the price field to allow back compatibility
But yet don't expose the base price

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a7a5365b781..9e17f530e31 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,8 @@ All notable, unreleased changes to this project will be documented in this file.
 - Implement menus items reordering into the GraphQL API - #3958 by @NyanKiyoshi
 
 
+- Implement variant availability, introducing discounts in variants - #3948 by @NyanKiyoshi
+
 ## 2.5.0
 
 ### API
diff --git a/saleor/core/utils/__init__.py b/saleor/core/utils/__init__.py
index 073f9f2ae70..7a0564328c7 100644
--- a/saleor/core/utils/__init__.py
+++ b/saleor/core/utils/__init__.py
@@ -102,6 +102,8 @@ def format_money(money):
 
 
 def to_local_currency(price, currency):
+    if price is None:
+        return None
     if not settings.OPENEXCHANGERATES_API_KEY:
         return None
     if isinstance(price, MoneyRange):
diff --git a/saleor/dashboard/product/views.py b/saleor/dashboard/product/views.py
index 6cdf372964d..14754eda381 100644
--- a/saleor/dashboard/product/views.py
+++ b/saleor/dashboard/product/views.py
@@ -15,7 +15,7 @@
 from ...product.models import (
     Attribute, AttributeValue, Product, ProductImage, ProductType,
     ProductVariant)
-from ...product.utils.availability import get_availability
+from ...product.utils.availability import get_product_availability
 from ...product.utils.costs import (
     get_margin_for_variant, get_product_costs_data)
 from ..views import staff_member_required
@@ -48,7 +48,7 @@ def product_details(request, pk):
     product = get_object_or_404(products, pk=pk)
     variants = product.variants.all()
     images = product.images.all()
-    availability = get_availability(
+    availability = get_product_availability(
         product, discounts=request.discounts, taxes=request.taxes)
     sale_price = availability.price_range_undiscounted
     discounted_price = availability.price_range
diff --git a/saleor/graphql/core/types/__init__.py b/saleor/graphql/core/types/__init__.py
index 9af712f3a2e..639a476a9d4 100644
--- a/saleor/graphql/core/types/__init__.py
+++ b/saleor/graphql/core/types/__init__.py
@@ -1,6 +1,6 @@
 from .common import (
-    CountryDisplay, Error, Image, LanguageDisplay, PermissionDisplay,
-    SeoInput, Weight)
+    CountryDisplay, Error, Image, LanguageDisplay, PermissionDisplay, SeoInput,
+    Weight)
 from .money import (
     VAT, Money, MoneyRange, ReducedRate, TaxedMoney, TaxedMoneyRange)
 from .upload import Upload
diff --git a/saleor/graphql/product/types.py b/saleor/graphql/product/types.py
index 82a4f1306e0..83d6185a263 100644
--- a/saleor/graphql/product/types.py
+++ b/saleor/graphql/product/types.py
@@ -12,7 +12,8 @@
 from ...product.templatetags.product_images import (
     get_product_image_thumbnail, get_thumbnail)
 from ...product.utils import calculate_revenue_for_variant
-from ...product.utils.availability import get_availability
+from ...product.utils.availability import (
+    get_product_availability, get_variant_availability)
 from ...product.utils.costs import (
     get_margin_for_variant, get_product_costs_data)
 from ..core.connection import CountableDjangoObjectType
@@ -181,6 +182,56 @@ class ProductOrder(graphene.InputObjectType):
         description='Specifies the direction in which to sort products')
 
 
+class BasePricingInfo(graphene.ObjectType):
+    available = graphene.Boolean(
+        description='Whether it is in stock and visible or not.',
+        deprecation_reason=(
+            'This has been moved to the parent type as \'is_available\'.'))
+    on_sale = graphene.Boolean(
+        description='Whether it is in sale or not.')
+    discount = graphene.Field(
+        TaxedMoney,
+        description='The discount amount if in sale (null otherwise).')
+    discount_local_currency = graphene.Field(
+        TaxedMoney,
+        description='The discount amount in the local currency.')
+
+
+class VariantPricingInfo(BasePricingInfo):
+    discount_local_currency = graphene.Field(
+        TaxedMoney,
+        description='The discount amount in the local currency.')
+    price = graphene.Field(
+        TaxedMoney,
+        description='The price, with any discount subtracted.')
+    price_undiscounted = graphene.Field(
+        TaxedMoney,
+        description='The price without any discount.')
+    price_local_currency = graphene.Field(
+        TaxedMoney,
+        description='The discounted price in the local currency.')
+
+    class Meta:
+        description = 'Represents availability of a variant in the storefront.'
+
+
+class ProductPricingInfo(BasePricingInfo):
+    price_range = graphene.Field(
+        TaxedMoneyRange,
+        description='The discounted price range of the product variants.')
+    price_range_undiscounted = graphene.Field(
+        TaxedMoneyRange,
+        description='The undiscounted price range of the product variants.')
+    price_range_local_currency = graphene.Field(
+        TaxedMoneyRange,
+        description=(
+            'The discounted price range of the product variants '
+            'in the local currency.'))
+
+    class Meta:
+        description = 'Represents availability of a product in the storefront.'
+
+
 class ProductVariant(CountableDjangoObjectType):
     stock_quantity = graphene.Int(
         required=True, description='Quantity of a product available for sale.')
@@ -190,7 +241,22 @@ class ProductVariant(CountableDjangoObjectType):
             """Override the base price of a product if necessary.
                A value of `null` indicates that the default product
                price is used."""))
-    price = graphene.Field(Money, description='Price of the product variant.')
+    price = graphene.Field(
+        Money, description='Price of the product variant.',
+        deprecation_reason=(
+            'Has been replaced by \'pricing.price_undiscounted\''))
+    availability = graphene.Field(
+        VariantPricingInfo, description=dedent(
+            """Informs about variant's availability in the
+               storefront, current price and discounted price."""),
+        deprecation_reason='Has been renamed to \'pricing\'.')
+    pricing = graphene.Field(
+        VariantPricingInfo,
+        description=dedent(
+            """Lists the storefront variant's pricing,
+            the current price and discounts, only meant for displaying"""))
+    is_available = graphene.Boolean(
+        description='Whether the variant is in stock and visible or not.')
     attributes = graphene.List(
         graphene.NonNull(SelectedAttribute), required=True,
         description='List of attributes assigned to this variant.')
@@ -254,6 +320,19 @@ def resolve_price(self, *_args):
             self.price_override
             if self.price_override is not None else self.product.price)
 
+    @gql_optimizer.resolver_hints(
+        prefetch_related=('product', ), only=['price_override'])
+    def resolve_pricing(self, info):
+        context = info.context
+        availability = get_variant_availability(
+            self, context.discounts, context.taxes, context.currency)
+        return VariantPricingInfo(**availability._asdict())
+
+    resolve_availability = resolve_pricing
+
+    def resolve_is_available(self, _info):
+        return self.is_available
+
     @permission_required('product.manage_products')
     def resolve_price_override(self, *_args):
         return self.price_override
@@ -292,19 +371,6 @@ def get_node(cls, info, id):
             return None
 
 
-class ProductAvailability(graphene.ObjectType):
-    available = graphene.Boolean()
-    on_sale = graphene.Boolean()
-    discount = graphene.Field(TaxedMoney)
-    discount_local_currency = graphene.Field(TaxedMoney)
-    price_range = graphene.Field(TaxedMoneyRange)
-    price_range_undiscounted = graphene.Field(TaxedMoneyRange)
-    price_range_local_currency = graphene.Field(TaxedMoneyRange)
-
-    class Meta:
-        description = 'Represents availability of a product in the storefront.'
-
-
 class Product(CountableDjangoObjectType):
     url = graphene.String(
         description='The storefront URL for the product.', required=True)
@@ -317,13 +383,25 @@ class Product(CountableDjangoObjectType):
         Image, description='The main thumbnail for a product.',
         size=graphene.Argument(graphene.Int, description='Size of thumbnail'))
     availability = graphene.Field(
-        ProductAvailability, description=dedent(
+        ProductPricingInfo,
+        description=dedent(
             """Informs about product's availability in the
-               storefront, current price and discounts."""))
+               storefront, current price and discounts."""),
+        deprecation_reason='Has been renamed to \'pricing\'.')
+    pricing = graphene.Field(
+        ProductPricingInfo, description=dedent(
+            """Lists the storefront product's pricing,
+            the current price and discounts, only meant for displaying."""))
+    is_available = graphene.Boolean(
+        description='Whether the product is in stock and visible or not.')
+    base_price = graphene.Field(
+        Money,
+        description='The product\'s default base price.')
     price = graphene.Field(
         Money,
-        description=dedent("""The product's base price (without any discounts
-        applied)."""))
+        description='The product\'s default base price.',
+        deprecation_reason=(
+            'Has been replaced by \'basePrice\''))
     tax_rate = TaxRateType(description='A type of tax rate.')
     attributes = graphene.List(
         graphene.NonNull(SelectedAttribute), required=True,
@@ -396,11 +474,27 @@ def resolve_url(self, *_args):
     @gql_optimizer.resolver_hints(
         prefetch_related=('variants', 'collections'),
         only=['publication_date', 'charge_taxes', 'price', 'tax_rate'])
-    def resolve_availability(self, info):
+    def resolve_pricing(self, info):
         context = info.context
-        availability = get_availability(
+        availability = get_product_availability(
             self, context.discounts, context.taxes, context.currency)
-        return ProductAvailability(**availability._asdict())
+        return ProductPricingInfo(**availability._asdict())
+
+    resolve_availability = resolve_pricing
+
+    def resolve_is_available(self, _info):
+        return self.is_available
+
+    @permission_required('product.manage_products')
+    def resolve_base_price(self, _info):
+        return self.price
+
+    @gql_optimizer.resolver_hints(
+        prefetch_related=('variants', 'collections'),
+        only=['publication_date', 'charge_taxes', 'price', 'tax_rate'])
+    def resolve_price(self, info):
+        price_range = self.get_price_range(info.context.discounts)
+        return price_range.start.net
 
     @gql_optimizer.resolver_hints(
         prefetch_related='product_type__product_attributes__values')
diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index c9aafc551aa..6168e0c9bd0 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -1684,8 +1684,11 @@ type Product implements Node {
   url: String!
   thumbnailUrl(size: Int): String @deprecated(reason: "thumbnailUrl is deprecated, use\\n         thumbnail instead")
   thumbnail(size: Int): Image
-  availability: ProductAvailability
-  price: Money
+  availability: ProductPricingInfo @deprecated(reason: "Has been renamed to 'pricing'.")
+  pricing: ProductPricingInfo
+  isAvailable: Boolean
+  basePrice: Money
+  price: Money @deprecated(reason: "Has been replaced by 'basePrice'")
   taxRate: TaxRateType
   attributes: [SelectedAttribute!]!
   purchaseCost: MoneyRange
@@ -1698,16 +1701,6 @@ type Product implements Node {
   translation(languageCode: LanguageCodeEnum!): ProductTranslation
 }
 
-type ProductAvailability {
-  available: Boolean
-  onSale: Boolean
-  discount: TaxedMoney
-  discountLocalCurrency: TaxedMoney
-  priceRange: TaxedMoneyRange
-  priceRangeUndiscounted: TaxedMoneyRange
-  priceRangeLocalCurrency: TaxedMoneyRange
-}
-
 type ProductBulkDelete {
   errors: [Error!]
   count: Int!
@@ -1835,6 +1828,16 @@ enum ProductOrderField {
   DATE
 }
 
+type ProductPricingInfo {
+  available: Boolean @deprecated(reason: "This has been moved to the parent type as 'is_available'.")
+  onSale: Boolean
+  discount: TaxedMoney
+  discountLocalCurrency: TaxedMoney
+  priceRange: TaxedMoneyRange
+  priceRangeUndiscounted: TaxedMoneyRange
+  priceRangeLocalCurrency: TaxedMoneyRange
+}
+
 type ProductTranslate {
   errors: [Error!]
   product: Product
@@ -1921,7 +1924,10 @@ type ProductVariant implements Node {
   weight: Weight
   stockQuantity: Int!
   priceOverride: Money
-  price: Money
+  price: Money @deprecated(reason: "Has been replaced by 'pricing.price_undiscounted'")
+  availability: VariantPricingInfo @deprecated(reason: "Has been renamed to 'pricing'.")
+  pricing: VariantPricingInfo
+  isAvailable: Boolean
   attributes: [SelectedAttribute!]!
   costPrice: Money
   margin: Int
@@ -2577,6 +2583,16 @@ type VariantImageUnassign {
   image: ProductImage
 }
 
+type VariantPricingInfo {
+  available: Boolean @deprecated(reason: "This has been moved to the parent type as 'is_available'.")
+  onSale: Boolean
+  discount: TaxedMoney
+  discountLocalCurrency: TaxedMoney
+  price: TaxedMoney
+  priceUndiscounted: TaxedMoney
+  priceLocalCurrency: TaxedMoney
+}
+
 type VerifyToken {
   payload: GenericScalar
   user: User
diff --git a/saleor/product/models.py b/saleor/product/models.py
index 86f3844a783..493e8cd21b5 100644
--- a/saleor/product/models.py
+++ b/saleor/product/models.py
@@ -143,6 +143,10 @@ def __repr__(self):
     def __str__(self):
         return self.name
 
+    @property
+    def is_available(self):
+        return self.is_visible and self.is_in_stock()
+
     def get_absolute_url(self):
         return reverse(
             'product:details',
@@ -227,6 +231,14 @@ def __str__(self):
     def quantity_available(self):
         return max(self.quantity - self.quantity_allocated, 0)
 
+    @property
+    def is_visible(self):
+        return self.product.is_visible
+
+    @property
+    def is_available(self):
+        return self.product.is_in_stock()
+
     def check_quantity(self, quantity):
         """Check if there is at least the given quantity in stock
         if stock handling is enabled.
diff --git a/saleor/product/utils/availability.py b/saleor/product/utils/availability.py
index be9957f67ed..eca7f19896d 100644
--- a/saleor/product/utils/availability.py
+++ b/saleor/product/utils/availability.py
@@ -1,5 +1,12 @@
 from collections import namedtuple
+from decimal import Decimal
 
+from typing import Union
+
+from prices import TaxedMoneyRange
+
+from saleor.graphql.core.types import MoneyRange
+from saleor.product.models import ProductVariant, Product
 from ...core.utils import to_local_currency
 from .. import ProductAvailabilityStatus, VariantAvailabilityStatus
 
@@ -8,10 +15,15 @@
         'available', 'on_sale', 'price_range', 'price_range_undiscounted',
         'discount', 'price_range_local_currency', 'discount_local_currency'))
 
+VariantAvailability = namedtuple(
+    'ProductAvailability', (
+        'available', 'on_sale', 'price', 'price_undiscounted',
+        'discount', 'price_local_currency', 'discount_local_currency'))
+
 
 def products_with_availability(products, discounts, taxes, local_currency):
     for product in products:
-        yield (product, get_availability(
+        yield (product, get_product_availability(
             product, discounts, taxes, local_currency))
 
 
@@ -45,41 +57,86 @@ def get_variant_availability_status(variant):
     return VariantAvailabilityStatus.AVAILABLE
 
 
-def get_availability(product, discounts=None, taxes=None, local_currency=None):
-    # In default currency
-    price_range = product.get_price_range(discounts=discounts, taxes=taxes)
-    undiscounted = product.get_price_range(taxes=taxes)
-    if undiscounted.start > price_range.start:
-        discount = undiscounted.start - price_range.start
-    else:
-        discount = None
+def _get_total_discount(
+        undiscounted: Union[MoneyRange, TaxedMoneyRange, Decimal],
+        discounted: Union[MoneyRange, TaxedMoneyRange, Decimal]):
+    """Subtracts two prices that are whether a price range or decimal prices
+    and return their total discount, if any. Otherwise, it returns None."""
+    if not isinstance(undiscounted, (MoneyRange, TaxedMoneyRange)):
+        if undiscounted > discounted:
+            return undiscounted - discounted
+    elif undiscounted.start > discounted.start:
+        return undiscounted.start - discounted.stop
+    return None
+
+
+def _get_product_price_range(
+        discounted: Union[MoneyRange, TaxedMoneyRange],
+        undiscounted: Union[MoneyRange, TaxedMoneyRange], local_currency=None):
+    price_range_local = None
+    discount_local_currency = None
 
-    # Local currency
     if local_currency:
         price_range_local = to_local_currency(
-            price_range, local_currency)
+            discounted, local_currency)
         undiscounted_local = to_local_currency(
             undiscounted, local_currency)
-        if (undiscounted_local and
-                undiscounted_local.start > price_range_local.start):
+        if (undiscounted_local
+                and undiscounted_local.start > price_range_local.start):
             discount_local_currency = (
                 undiscounted_local.start - price_range_local.start)
-        else:
-            discount_local_currency = None
-    else:
-        price_range_local = None
-        discount_local_currency = None
 
-    is_available = product.is_in_stock() and product.is_visible
-    is_on_sale = (
-        product.is_visible and discount is not None and
-        undiscounted.start != price_range.start)
+    return price_range_local, discount_local_currency
+
+
+def get_product_availability(
+        product: Product,
+        discounts=None, taxes=None, local_currency=None
+) -> ProductAvailability:
+
+    discounted = product.get_price_range(discounts=discounts, taxes=taxes)
+    undiscounted = product.get_price_range(taxes=taxes)
+
+    discount = _get_total_discount(undiscounted, discounted)
+    price_range_local, discount_local_currency = _get_product_price_range(
+        discounted, undiscounted, local_currency)
+
+    is_on_sale = product.is_visible and discount is not None
 
     return ProductAvailability(
-        available=is_available,
+        available=product.is_available,
         on_sale=is_on_sale,
-        price_range=price_range,
+        price_range=discounted,
         price_range_undiscounted=undiscounted,
         discount=discount,
         price_range_local_currency=price_range_local,
         discount_local_currency=discount_local_currency)
+
+
+def get_variant_availability(
+        variant: ProductVariant,
+        discounts=None, taxes=None, local_currency=None
+) -> VariantAvailability:
+
+    discounted = variant.get_price(discounts=discounts, taxes=taxes)
+    undiscounted = variant.get_price(taxes=taxes)
+
+    discount = _get_total_discount(undiscounted, discounted)
+
+    if local_currency:
+        price_local_currency = to_local_currency(discounted, local_currency)
+        discount_local_currency = to_local_currency(discount, local_currency)
+    else:
+        price_local_currency = None
+        discount_local_currency = None
+
+    is_on_sale = variant.is_visible and discount is not None
+
+    return VariantAvailability(
+        available=variant.is_available,
+        on_sale=is_on_sale,
+        price=discounted,
+        price_undiscounted=undiscounted,
+        discount=discount,
+        price_local_currency=price_local_currency,
+        discount_local_currency=discount_local_currency)
diff --git a/saleor/product/utils/variants_picker.py b/saleor/product/utils/variants_picker.py
index a2896efe991..e423c56c422 100644
--- a/saleor/product/utils/variants_picker.py
+++ b/saleor/product/utils/variants_picker.py
@@ -5,12 +5,13 @@
 from ...core.utils import to_local_currency
 from ...core.utils.taxes import display_gross_prices, get_tax_rate_by_name
 from ...seo.schema.product import variant_json_ld
-from .availability import get_availability
+from .availability import get_product_availability
 
 
 def get_variant_picker_data(
         product, discounts=None, taxes=None, local_currency=None):
-    availability = get_availability(product, discounts, taxes, local_currency)
+    availability = get_product_availability(
+        product, discounts, taxes, local_currency)
     variants = product.variants.all()
     data = {'variantAttributes': [], 'variants': []}
 
diff --git a/saleor/product/views.py b/saleor/product/views.py
index 88265a599f0..0105b967017 100644
--- a/saleor/product/views.py
+++ b/saleor/product/views.py
@@ -21,7 +21,7 @@
     handle_checkout_form, products_for_checkout, products_for_products_list,
     products_with_details)
 from .utils.attributes import get_product_attributes_data
-from .utils.availability import get_availability
+from .utils.availability import get_product_availability
 from .utils.digital_products import digital_content_url_is_valid
 from .utils.variants_picker import get_variant_picker_data
 
@@ -65,7 +65,7 @@ def product_details(request, slug, product_id, form=None):
         product.publication_date is None or product.publication_date <= today)
     if form is None:
         form = handle_checkout_form(request, product, create_checkout=False)[0]
-    availability = get_availability(
+    availability = get_product_availability(
         product, discounts=request.discounts, taxes=request.taxes,
         local_currency=request.currency)
     product_images = get_product_images(product)
diff --git a/tests/api/test_graphql.py b/tests/api/test_graphql.py
index 0cf99f352d1..f6ad192a168 100644
--- a/tests/api/test_graphql.py
+++ b/tests/api/test_graphql.py
@@ -110,13 +110,7 @@ def test_real_query(user_api_client, product):
     fragment ProductFragmentQuery on Product {
         id
         name
-        price {
-            amount
-            currency
-            localized
-            __typename
-        }
-        availability {
+        pricing {
             ...ProductPriceFragmentQuery
             __typename
         }
@@ -126,7 +120,7 @@ def test_real_query(user_api_client, product):
         __typename
     }
 
-    fragment ProductPriceFragmentQuery on ProductAvailability {
+    fragment ProductPriceFragmentQuery on ProductPricingInfo {
         available
         discount {
             gross {
diff --git a/tests/api/test_product.py b/tests/api/test_product.py
index 0081aac4168..a2a4adeb3ea 100644
--- a/tests/api/test_product.py
+++ b/tests/api/test_product.py
@@ -103,7 +103,11 @@ def test_product_query(staff_api_client, product, permission_manage_products):
                             name
                             stockQuantity
                         }
-                        availability {
+                        isAvailable
+                        price {
+                            amount
+                        }
+                        pricing {
                             available,
                             priceRange {
                                 start {
@@ -148,7 +152,7 @@ def test_product_query(staff_api_client, product, permission_manage_products):
     product_data = product_edges_data[0]['node']
     assert product_data['name'] == product.name
     assert product_data['url'] == product.get_absolute_url()
-    gross = product_data['availability']['priceRange']['start']['gross']
+    gross = product_data['pricing']['priceRange']['start']['gross']
     assert float(gross['amount']) == float(product.price.amount)
     from saleor.product.utils.costs import get_product_costs_data
     purchase_cost, margin = get_product_costs_data(product)
@@ -156,6 +160,9 @@ def test_product_query(staff_api_client, product, permission_manage_products):
         'purchaseCost']['start']['amount']
     assert purchase_cost.stop.amount == product_data[
         'purchaseCost']['stop']['amount']
+    assert product_data['isAvailable'] is product.is_visible
+    assert product_data['pricing']['available'] is product.is_visible
+    assert product_data['price']['amount'] == product.price.amount
     assert margin[0] == product_data['margin']['start']
     assert margin[1] == product_data['margin']['stop']
 
@@ -393,8 +400,14 @@ def test_sort_products(user_api_client, product):
         products(sortBy: %(sort_by_product_order)s, first: 2) {
             edges {
                 node {
-                    price {
-                        amount
+                    pricing {
+                        priceRangeUndiscounted {
+                            start {
+                                gross {
+                                    amount
+                                }
+                            }
+                        }
                     }
                     updatedAt
                 }
@@ -403,25 +416,23 @@ def test_sort_products(user_api_client, product):
     }
     """
 
+    def _get_node_price(data, node):
+        return (
+            data['data']['products']['edges'][node]['node']['pricing'][
+                'priceRangeUndiscounted']['start']['gross']['amount'])
+
     asc_price_query = query % {
         'sort_by_product_order': '{field: PRICE, direction:ASC}'}
     response = user_api_client.post_graphql(asc_price_query)
     content = get_graphql_content(response)
-    price_0 = content['data']['products']['edges'][0]['node']['price'][
-        'amount']
-    price_1 = content['data']['products']['edges'][1]['node']['price'][
-        'amount']
-    assert price_0 < price_1
+
+    assert _get_node_price(content, 0) < _get_node_price(content, 1)
 
     desc_price_query = query % {
         'sort_by_product_order': '{field: PRICE, direction:DESC}'}
     response = user_api_client.post_graphql(desc_price_query)
     content = get_graphql_content(response)
-    price_0 = content['data']['products']['edges'][0]['node']['price'][
-        'amount']
-    price_1 = content['data']['products']['edges'][1]['node']['price'][
-        'amount']
-    assert price_0 > price_1
+    assert _get_node_price(content, 0) > _get_node_price(content, 1)
 
     asc_date_query = query % {
         'sort_by_product_order': '{field: DATE, direction:ASC}'}
@@ -1518,7 +1529,7 @@ def test_product_update_variants_names(mock__update_variants_names,
     variant_attributes = [product_type.variant_attributes.first()]
     variant_attr_ids = [attr.pk for attr in variant_attributes]
     update_variants_names(product_type.pk, variant_attr_ids)
-    mock__update_variants_names.call_count == 1
+    assert mock__update_variants_names.call_count == 1
 
 
 def test_product_variants_by_ids(user_api_client, variant):
@@ -1846,3 +1857,28 @@ def test_bulk_unpublish_products(
     assert content['data']['productBulkPublish']['count'] == len(
         product_list)
     assert not any(product.is_published for product in product_list)
+
+
+def test_product_base_price_permission(
+        staff_api_client, permission_manage_products, product):
+    query = """
+    query basePrice($productID: ID!) {
+        product(id: $productID) {
+            basePrice {
+                amount
+            }
+        }
+    }
+    """
+    product_id = graphene.Node.to_global_id('Product', product.id)
+
+    variables = {'productID': product_id}
+    permissions = [permission_manage_products]
+
+    response = staff_api_client.post_graphql(query, variables, permissions)
+    content = get_graphql_content(response)
+
+    assert 'basePrice' in content['data']['product']
+    assert (
+        content['data']['product']['basePrice'][
+            'amount'] == product.price.amount)
diff --git a/tests/api/test_variant_pricing.py b/tests/api/test_variant_pricing.py
new file mode 100644
index 00000000000..c2fa76706f2
--- /dev/null
+++ b/tests/api/test_variant_pricing.py
@@ -0,0 +1,129 @@
+from unittest.mock import Mock
+
+from saleor.product.models import ProductVariant
+from saleor.product.utils.availability import get_variant_availability
+from tests.api.utils import get_graphql_content
+
+QUERY_GET_VARIANT_PRICING = """
+query {
+  products(first: 1) {
+    edges {
+      node {
+        variants {
+          pricing {
+            available
+            onSale
+            
+            discount {
+              currency
+              net {
+                amount
+              }
+            }
+            
+            priceUndiscounted {
+              currency
+              net {
+                amount
+              }
+            }
+            
+            price {
+              currency
+              net {
+                amount
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
+"""
+
+
+def test_get_variant_pricing_on_sale(api_client, sale, product):
+    price = product.price
+    discounted_price = price.amount - sale.value
+
+    response = api_client.post_graphql(QUERY_GET_VARIANT_PRICING, {})
+    content = get_graphql_content(response)
+
+    pricing = (
+        content['data']['products']['edges'][0]['node']
+        ['variants'][0]['pricing'])
+
+    # ensure the availability was correctly retrieved and sent
+    assert pricing
+
+    # check availability
+    assert pricing['available'] is True
+    assert pricing['onSale'] is True
+
+    # check the discount
+    assert pricing['discount']['currency'] == price.currency
+    assert pricing['discount']['net']['amount'] == discounted_price
+
+    # check the undiscounted price
+    assert (
+        pricing['priceUndiscounted']['currency'] == price.currency)
+    assert (
+        pricing['priceUndiscounted']['net']['amount'] == price.amount)
+
+    # check the discounted price
+    assert pricing['price']['currency'] == price.currency
+    assert pricing['price']['net']['amount'] == discounted_price
+
+
+def test_get_variant_pricing_not_on_sale(api_client, product):
+    response = api_client.post_graphql(QUERY_GET_VARIANT_PRICING, {})
+    content = get_graphql_content(response)
+
+    pricing = (
+        content['data']['products']['edges'][0]['node']
+        ['variants'][0]['pricing'])
+
+    # ensure the availability was correctly retrieved and sent
+    assert pricing
+
+    # check availability
+    assert pricing['available'] is True
+    assert pricing['onSale'] is False
+
+    # check the discount
+    assert pricing['discount'] is None
+
+    # check the undiscounted price
+    assert (
+        pricing['priceUndiscounted']['currency'] == product.price.currency)
+    assert (
+        pricing['priceUndiscounted']['net']['amount'] == product.price.amount)
+
+    # check the discounted price
+    assert pricing['price']['currency'] == product.price.currency
+    assert pricing['price']['net']['amount'] == product.price.amount
+
+
+def test_variant_pricing(
+        variant: ProductVariant, monkeypatch, settings, taxes):
+    pricing = get_variant_availability(variant)
+    assert pricing.price == variant.get_price()
+    assert pricing.price_local_currency is None
+
+    monkeypatch.setattr(
+        'django_prices_openexchangerates.models.get_rates',
+        lambda c: {'PLN': Mock(rate=2)})
+
+    settings.DEFAULT_COUNTRY = 'PL'
+    settings.OPENEXCHANGERATES_API_KEY = 'fake-key'
+
+    pricing = get_variant_availability(variant, local_currency='PLN')
+    assert pricing.price_local_currency.currency == 'PLN'
+    assert pricing.available
+
+    pricing = get_variant_availability(variant, taxes=taxes)
+    assert pricing.price.tax.amount
+    assert pricing.price_undiscounted.tax.amount
+    assert pricing.price_undiscounted.tax.amount
+    assert pricing.available
diff --git a/tests/test_product_availability.py b/tests/test_product_availability.py
index ac966a6cc93..ec8b1940745 100644
--- a/tests/test_product_availability.py
+++ b/tests/test_product_availability.py
@@ -4,7 +4,7 @@
 from saleor.product import (
     ProductAvailabilityStatus, VariantAvailabilityStatus, models)
 from saleor.product.utils.availability import (
-    get_availability, get_product_availability_status,
+    get_product_availability, get_product_availability_status,
     get_variant_availability_status)
 
 
@@ -70,7 +70,7 @@ def test_variant_availability_status(unavailable_product):
 
 
 def test_availability(product, monkeypatch, settings, taxes):
-    availability = get_availability(product)
+    availability = get_product_availability(product)
     assert availability.price_range == product.get_price_range()
     assert availability.price_range_local_currency is None
 
@@ -79,11 +79,11 @@ def test_availability(product, monkeypatch, settings, taxes):
         lambda c: {'PLN': Mock(rate=2)})
     settings.DEFAULT_COUNTRY = 'PL'
     settings.OPENEXCHANGERATES_API_KEY = 'fake-key'
-    availability = get_availability(product, local_currency='PLN')
+    availability = get_product_availability(product, local_currency='PLN')
     assert availability.price_range_local_currency.start.currency == 'PLN'
     assert availability.available
 
-    availability = get_availability(product, taxes=taxes)
+    availability = get_product_availability(product, taxes=taxes)
     assert availability.price_range.start.tax.amount
     assert availability.price_range.stop.tax.amount
     assert availability.price_range_undiscounted.start.tax.amount


Code review comments:
diff_hunk: @@ -227,6 +231,14 @@ def __str__(self):
     def quantity_available(self):
         return max(self.quantity - self.quantity_allocated, 0)
 
+    @property
+    def is_visible(self):
+        return self.product.is_visible
+
+    @property
+    def is_available(self):
+        return self.product.is_in_stock()
path: saleor/product/models.py
text: @user1:
Shouldn't this use `product.is_available`?
```suggestion
        return self.product.is_available()
```


@author:
Thanks!