Repository: saleor/saleor
Pull request: Drop country middleware
This PR drops the country middleware (`saleor.core.middleware.country`), which was using GeoIP to determine the user's country. In the new approach, user location is provided as an argument in queries or mutations that need it for tax or stock calculations.

Note: this PR removes `CheckoutPaymentCreate.billingAddress` input field which is a breaking change. The idea is that we should always use billing address from the checkout instance, to have one way of providing billing addresses for payments. This field will be marked as deprecated in Saleor 2.11.

Fixes https://github.com/mirumee/saleor/issues/4315

Todo:
- [x] update test_variant_availability.py tests to use new address parameter
- [x] add tests for queries that use the new param
- [x] deprecate `CheckoutPaymentCreate.billingAddress` input on 2.11

# Impact

* [ ] New migrations
* [x] New/Updated API fields or mutations
* [x] Deprecated API fields or mutations
* [x] Removed API types, fields, or mutations
* [x] Documentation needs to be updated

# Pull Request Checklist

<!-- Please keep this section. It will make the maintainer's life easier. -->

* [ ] Privileged queries and mutations are guarded by proper permission checks
* [ ] Database queries are optimized and the number of queries is constant
* [x] Database migration files are up to date
* [x] The changes are tested
* [x] GraphQL schema and type definitions are up to date
* [x] Changes are mentioned in the changelog


Problem statement:
Allow passing `country` and `currency` to `Product.pricing` and `ProductVariant.pricing`
### What I'm trying to achieve
We need to allow people to explicitly switch countries and have the correct prices displayed.

Current implementation relies on `request.country` but when using the GraphQL API it's always set to whatever GeoIP returned.

### Describe a proposed solution
Currently, the API looks like this:

```graphql
type Product {
  pricing: ProductPricingInfo
}

type ProductVariant {
  pricing: VariantPricingInfo
}

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 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 TaxedMoney {
  currency: String!
  gross: Money!
  net: Money!
  tax: Money!
}

type Money {
  currency: String!
  amount: Float!
  localized: String!
}
```

I propose that we allow `country` and `currency` to be passed explicitly and that we drop the `Money.localized` field:

```graphql
type Product {
  pricing(country: CountryCode, currency: String): ProductPricingInfo
}

type ProductVariant {
  pricing(country: CountryCode, currency: String): VariantPricingInfo
}

type Money {
  currency: String!
  amount: Float!
}
```

If a value is not given, use the store's default country or currency (instead of falling back to GeoIP).

If a currency is given, return values in that currency (using the currency exchange rates) or return `null` if unable to do so.

If a country is given, calculate taxes according to that country's tax regulations.

Hints from issue discussion:
Will it affect product filtering? How to filter products by price without knowing taxes?
We've talked about this, ideally passing `country` and `currency` should be possible at `products` level if we ever want to allow products to be filtered or sorted with regard to local taxes or a non-standard currency.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

Queries are one thing; the other is that we need to know the country in checkout mutations that check the stock quantity so that we can pick the right warehouse. We need to get rid of usages of `request.country` (or `info.context.country` in API) and instead have a country code explicitly provided by the user.

Also, we currently use countries to determine a warehouse, but I think we want to have better granularity and e.g. allow to define warehouses per state (that would be the use case in the US). Does it also affect pricing queries @patrys?

Commit message: Fix LGTM issues

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ed08fca4ca4..edc40ed4088 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ All notable, unreleased changes to this project will be documented in this file.
 - Drop authorization keys API - #6631 by @maarcingebala
 - Drop `type` field from `AttributeValue` type - #6710 by @IKarbowiak
 - Drop `apply_taxes_to_shipping_price_range` plugin hook - #6746 by @maarcingebala
+- Remove resolving user's location from GeoIP; drop `PaymentInput.billingAddress` input field - #6784 by @maarcingebala
 
 ### Other
 
diff --git a/saleor/core/middleware.py b/saleor/core/middleware.py
index 36fe239aca4..483ff4f3c66 100644
--- a/saleor/core/middleware.py
+++ b/saleor/core/middleware.py
@@ -7,13 +7,11 @@
 from django.utils import timezone
 from django.utils.functional import SimpleLazyObject
 from django.utils.translation import get_language
-from django_countries.fields import Country
 
 from ..discount.utils import fetch_discounts
 from ..plugins.manager import get_plugins_manager
 from . import analytics
 from .jwt import JWT_REFRESH_TOKEN_COOKIE_NAME, jwt_decode
-from .utils import get_client_ip, get_country_by_ip, get_currency_for_country
 
 logger = logging.getLogger(__name__)
 
@@ -60,26 +58,11 @@ def _discounts_middleware(request):
     return _discounts_middleware
 
 
-def country(get_response):
-    """Detect the user's country and assign it to `request.country`."""
-
-    def _country_middleware(request):
-        client_ip = get_client_ip(request)
-        if client_ip:
-            request.country = get_country_by_ip(client_ip)
-        if not request.country:
-            request.country = Country(settings.DEFAULT_COUNTRY)
-        return get_response(request)
-
-    return _country_middleware
-
-
 def currency(get_response):
     """Take a country and assign a matching currency to `request.currency`."""
 
     def _currency_middleware(request):
-        if hasattr(request, "country") and request.country is not None:
-            request.currency = get_currency_for_country(request.country)
+        request.currency = settings.DEFAULT_CURRENCY
         return get_response(request)
 
     return _currency_middleware
diff --git a/saleor/graphql/checkout/mutations.py b/saleor/graphql/checkout/mutations.py
index 3710e02c24d..22afec55e76 100644
--- a/saleor/graphql/checkout/mutations.py
+++ b/saleor/graphql/checkout/mutations.py
@@ -39,6 +39,7 @@
 from ..order.types import Order
 from ..product.types import ProductVariant
 from ..shipping.types import ShippingMethod
+from ..utils import get_user_country_context
 from .types import Checkout, CheckoutLine
 
 ERROR_DOES_NOT_SHIP = "This checkout doesn't need shipping"
@@ -286,19 +287,18 @@ def clean_channel(cls, channel_slug):
     @classmethod
     def clean_input(cls, info, instance: models.Checkout, data, input_cls=None):
         user = info.context.user
-        country = info.context.country.code
         channel = data.pop("channel")
         cleaned_input = super().clean_input(info, instance, data)
 
         cleaned_input["channel"] = channel
         cleaned_input["currency"] = channel.currency_code
 
-        # set country to one from shipping address
-        shipping_address = cleaned_input.get("shipping_address")
-        if shipping_address and shipping_address.country:
-            if shipping_address.country != country:
-                country = shipping_address.country
-        cleaned_input["country"] = country
+        shipping_address = cls.retrieve_shipping_address(user, data)
+        billing_address = cls.retrieve_billing_address(user, data)
+        country = get_user_country_context(
+            destination_address=shipping_address,
+            company_address=info.context.site.settings.company_address,
+        )
 
         # Resolve and process the lines, retrieving the variants and quantities
         lines = data.pop("lines", None)
@@ -308,14 +308,14 @@ def clean_input(cls, info, instance: models.Checkout, data, input_cls=None):
                 cleaned_input["quantities"],
             ) = cls.clean_checkout_lines(lines, country, cleaned_input["channel"].id)
 
-        cleaned_input["shipping_address"] = cls.retrieve_shipping_address(user, data)
-        cleaned_input["billing_address"] = cls.retrieve_billing_address(user, data)
-
         # Use authenticated user's email as default email
         if user.is_authenticated:
             email = data.pop("email", None)
             cleaned_input["email"] = email or user.email
 
+        cleaned_input["shipping_address"] = shipping_address
+        cleaned_input["billing_address"] = billing_address
+        cleaned_input["country"] = country
         return cleaned_input
 
     @classmethod
@@ -634,11 +634,10 @@ def perform_mutation(cls, _root, info, checkout_id, shipping_address):
 
         lines = fetch_checkout_lines(checkout)
 
-        country = info.context.country.code
-        # set country to one from shipping address
-        if shipping_address and shipping_address.country:
-            if shipping_address.country != country:
-                country = shipping_address.country
+        country = get_user_country_context(
+            destination_address=shipping_address,
+            company_address=info.context.site.settings.company_address,
+        )
         checkout.set_country(country, commit=True)
 
         # Resolve and process the lines, validating variants quantities
diff --git a/saleor/graphql/payment/mutations.py b/saleor/graphql/payment/mutations.py
index 418998af4b1..2dbdc426c05 100644
--- a/saleor/graphql/payment/mutations.py
+++ b/saleor/graphql/payment/mutations.py
@@ -11,7 +11,6 @@
 from ...payment.error_codes import PaymentErrorCode
 from ...payment.utils import create_payment, is_currency_supported
 from ..account.i18n import I18nMixin
-from ..account.types import AddressInput
 from ..checkout.types import Checkout
 from ..core.mutations import BaseMutation
 from ..core.scalars import PositiveDecimal
@@ -40,15 +39,6 @@ class PaymentInput(graphene.InputObjectType):
             "the checkout total will be used."
         ),
     )
-    billing_address = AddressInput(
-        required=False,
-        description=(
-            "[Deprecated] Billing address. If empty, the billing address associated "
-            "with the checkout instance will be used. Use `checkoutCreate` or "
-            "`checkoutBillingAddressUpdate` mutations to set it. This field will be "
-            "removed after 2020-07-31."
-        ),
-    )
     return_url = graphene.String(
         required=False,
         description=(
@@ -74,18 +64,6 @@ class Meta:
         error_type_class = common_types.PaymentError
         error_type_field = "payment_errors"
 
-    @classmethod
-    def clean_shipping_method(cls, checkout):
-        if not checkout.shipping_method:
-            raise ValidationError(
-                {
-                    "shipping_method": ValidationError(
-                        "Shipping method not set for this checkout.",
-                        code=PaymentErrorCode.SHIPPING_METHOD_NOT_SET,
-                    )
-                }
-            )
-
     @classmethod
     def clean_payment_amount(cls, info, checkout_total, amount):
         if amount != checkout_total.gross.amount:
diff --git a/saleor/graphql/product/tests/test_product.py b/saleor/graphql/product/tests/test_product.py
index 11875c8e727..600b2c0ccf6 100644
--- a/saleor/graphql/product/tests/test_product.py
+++ b/saleor/graphql/product/tests/test_product.py
@@ -1355,9 +1355,9 @@ def test_fetch_product_from_category_query(
     category = Category.objects.first()
     product = category.products.first()
     query = """
-    query {
-        category(id: "%(category_id)s") {
-            products(first: 20, channel: "%(channel_slug)s") {
+    query CategoryProducts($id: ID, $channel: String, $address: AddressInput) {
+        category(id: $id) {
+            products(first: 20, channel: $channel) {
                 edges {
                     node {
                         id
@@ -1393,8 +1393,8 @@ def test_fetch_product_from_category_query(
                                 stop
                             }
                         }
-                        isAvailable
-                        pricing {
+                        isAvailable(address: $address)
+                        pricing(address: $address) {
                             priceRange {
                                 start {
                                     gross {
@@ -1416,12 +1416,14 @@ def test_fetch_product_from_category_query(
             }
         }
     }
-    """ % {
-        "category_id": graphene.Node.to_global_id("Category", category.id),
-        "channel_slug": channel_USD.slug,
-    }
+    """
     staff_api_client.user.user_permissions.add(permission_manage_products)
-    response = staff_api_client.post_graphql(query)
+    variables = {
+        "id": graphene.Node.to_global_id("Category", category.id),
+        "channel": channel_USD.slug,
+        "address": {"country": "US"},
+    }
+    response = staff_api_client.post_graphql(query, variables)
     content = get_graphql_content(response)
     assert content["data"]["category"] is not None
     product_edges_data = content["data"]["category"]["products"]["edges"]
@@ -6312,10 +6314,10 @@ def test_product_variant_price(
     ).update(price_amount=variant_price_amount)
 
     query = """
-        query getProductVariants($id: ID!, $channel: String) {
+        query getProductVariants($id: ID!, $channel: String, $address: AddressInput) {
             product(id: $id, channel: $channel) {
                 variants {
-                    pricing {
+                    pricing(address: $address) {
                         priceUndiscounted {
                             gross {
                                 amount
@@ -6327,7 +6329,11 @@ def test_product_variant_price(
         }
         """
     product_id = graphene.Node.to_global_id("Product", variant.product.id)
-    variables = {"id": product_id, "channel": channel_USD.slug}
+    variables = {
+        "id": product_id,
+        "channel": channel_USD.slug,
+        "address": {"country": "US"},
+    }
     response = user_api_client.post_graphql(query, variables)
     content = get_graphql_content(response)
     data = content["data"]["product"]
diff --git a/saleor/graphql/product/tests/test_product_channel_listings_queries.py b/saleor/graphql/product/tests/test_product_channel_listings_queries.py
index e30c5bcd661..ad5b65a583b 100644
--- a/saleor/graphql/product/tests/test_product_channel_listings_queries.py
+++ b/saleor/graphql/product/tests/test_product_channel_listings_queries.py
@@ -3,10 +3,10 @@
 from ...tests.utils import get_graphql_content
 
 QUERY_PRICING_ON_PRODUCT_CHANNEL_LISTING = """
-query FetchProduct($id: ID, $channel: String) {
+query FetchProduct($id: ID, $channel: String, $address: AddressInput) {
   product(id: $id, channel: $channel) {
     id
-    pricing {
+    pricing(address: $address) {
       priceRangeUndiscounted {
         start {
           gross {
@@ -26,7 +26,7 @@
       channel {
         slug
       }
-      pricing {
+      pricing(address: $address) {
         priceRangeUndiscounted {
           start {
             gross {
@@ -55,6 +55,7 @@ def test_product_channel_listing_pricing_field(
     variables = {
         "id": graphene.Node.to_global_id("Product", product.pk),
         "channel": channel_USD.slug,
+        "address": {"country": "US"},
     }
     product.channel_listings.exclude(channel__slug=channel_USD.slug).delete()
 
diff --git a/saleor/graphql/product/tests/test_variant.py b/saleor/graphql/product/tests/test_variant.py
index 2eb548b49db..0a9eb9b1334 100644
--- a/saleor/graphql/product/tests/test_variant.py
+++ b/saleor/graphql/product/tests/test_variant.py
@@ -29,10 +29,15 @@ def test_fetch_variant(
     channel_USD,
 ):
     query = """
-    query ProductVariantDetails($id: ID!, $countyCode: CountryCode, $channel: String) {
+    query ProductVariantDetails(
+        $id: ID!, $address: AddressInput, $countryCode: CountryCode, $channel: String
+    ) {
         productVariant(id: $id, channel: $channel) {
             id
-            stocks(countryCode: $countyCode) {
+            deprecatedStocksByCountry: stocks(countryCode: $countryCode) {
+                id
+            }
+            stocksByAddress: stocks(address: $address) {
                 id
             }
             attributes {
@@ -92,7 +97,7 @@ def test_fetch_variant(
     site_settings.save(update_fields=["default_weight_unit"])
 
     variant_id = graphene.Node.to_global_id("ProductVariant", variant.pk)
-    variables = {"id": variant_id, "countyCode": "EU", "channel": channel_USD.slug}
+    variables = {"id": variant_id, "countryCode": "EU", "channel": channel_USD.slug}
     staff_api_client.user.user_permissions.add(permission_manage_products)
 
     # when
@@ -102,7 +107,11 @@ def test_fetch_variant(
     content = get_graphql_content(response)
     data = content["data"]["productVariant"]
     assert data["name"] == variant.name
-    assert len(data["stocks"]) == variant.stocks.count()
+
+    stocks_count = variant.stocks.count()
+    assert len(data["deprecatedStocksByCountry"]) == stocks_count
+    assert len(data["stocksByAddress"]) == stocks_count
+
     assert data["weight"]["value"] == 10000
     assert data["weight"]["unit"] == WeightUnitsEnum.G.name
     channel_listing_data = data["channelListings"][0]
diff --git a/saleor/graphql/product/tests/test_variant_availability.py b/saleor/graphql/product/tests/test_variant_availability.py
index e9bb1976868..d565c382cbb 100644
--- a/saleor/graphql/product/tests/test_variant_availability.py
+++ b/saleor/graphql/product/tests/test_variant_availability.py
@@ -27,9 +27,12 @@ def test_variant_quantity_available_without_country_code(
 
 
 QUERY_VARIANT_AVAILABILITY = """
-    query variantAvailability($id: ID!, $country: CountryCode, $channel: String) {
+    query variantAvailability(
+        $id: ID!, $country: CountryCode, $address: AddressInput, $channel: String
+    ) {
         productVariant(id: $id, channel: $channel) {
-            quantityAvailable(countryCode: $country)
+            deprecatedByCountry: quantityAvailable(countryCode: $country)
+            byAddress: quantityAvailable(address: $address)
         }
     }
 """
@@ -40,13 +43,14 @@ def test_variant_quantity_available_with_country_code(
 ):
     variables = {
         "id": graphene.Node.to_global_id("ProductVariant", variant_with_many_stocks.pk),
-        "country": COUNTRY_CODE,
+        "address": {"country": COUNTRY_CODE},
         "channel": channel_USD.slug,
     }
     response = api_client.post_graphql(QUERY_VARIANT_AVAILABILITY, variables)
     content = get_graphql_content(response)
     variant_data = content["data"]["productVariant"]
-    assert variant_data["quantityAvailable"] == 7
+    assert variant_data["deprecatedByCountry"] == 7
+    assert variant_data["byAddress"] == 7
 
 
 def test_variant_quantity_available_with_null_as_country_code(
@@ -60,7 +64,8 @@ def test_variant_quantity_available_with_null_as_country_code(
     response = api_client.post_graphql(QUERY_VARIANT_AVAILABILITY, variables)
     content = get_graphql_content(response)
     variant_data = content["data"]["productVariant"]
-    assert variant_data["quantityAvailable"] == 7
+    assert variant_data["deprecatedByCountry"] == 7
+    assert variant_data["byAddress"] == 7
 
 
 @override_settings(MAX_CHECKOUT_LINE_QUANTITY=15)
@@ -78,7 +83,8 @@ def test_variant_quantity_available_with_max(
     response = api_client.post_graphql(QUERY_VARIANT_AVAILABILITY, variables)
     content = get_graphql_content(response)
     variant_data = content["data"]["productVariant"]
-    assert variant_data["quantityAvailable"] == settings.MAX_CHECKOUT_LINE_QUANTITY
+    assert variant_data["deprecatedByCountry"] == settings.MAX_CHECKOUT_LINE_QUANTITY
+    assert variant_data["byAddress"] == settings.MAX_CHECKOUT_LINE_QUANTITY
 
 
 def test_variant_quantity_available_without_stocks(
@@ -93,7 +99,8 @@ def test_variant_quantity_available_without_stocks(
     response = api_client.post_graphql(QUERY_VARIANT_AVAILABILITY, variables)
     content = get_graphql_content(response)
     variant_data = content["data"]["productVariant"]
-    assert variant_data["quantityAvailable"] == 0
+    assert variant_data["deprecatedByCountry"] == 0
+    assert variant_data["byAddress"] == 0
 
 
 def test_variant_quantity_available_with_allocations(
@@ -111,7 +118,8 @@ def test_variant_quantity_available_with_allocations(
     response = api_client.post_graphql(QUERY_VARIANT_AVAILABILITY, variables)
     content = get_graphql_content(response)
     variant_data = content["data"]["productVariant"]
-    assert variant_data["quantityAvailable"] == 3
+    assert variant_data["deprecatedByCountry"] == 3
+    assert variant_data["byAddress"] == 3
 
 
 @override_settings(MAX_CHECKOUT_LINE_QUANTITY=15)
@@ -128,7 +136,8 @@ def test_variant_quantity_available_without_inventory_tracking(
     response = api_client.post_graphql(QUERY_VARIANT_AVAILABILITY, variables)
     content = get_graphql_content(response)
     variant_data = content["data"]["productVariant"]
-    assert variant_data["quantityAvailable"] == settings.MAX_CHECKOUT_LINE_QUANTITY
+    assert variant_data["deprecatedByCountry"] == settings.MAX_CHECKOUT_LINE_QUANTITY
+    assert variant_data["byAddress"] == settings.MAX_CHECKOUT_LINE_QUANTITY
 
 
 @override_settings(MAX_CHECKOUT_LINE_QUANTITY=15)
@@ -145,4 +154,5 @@ def test_variant_quantity_available_without_inventory_tracking_and_stocks(
     response = api_client.post_graphql(QUERY_VARIANT_AVAILABILITY, variables)
     content = get_graphql_content(response)
     variant_data = content["data"]["productVariant"]
-    assert variant_data["quantityAvailable"] == settings.MAX_CHECKOUT_LINE_QUANTITY
+    assert variant_data["deprecatedByCountry"] == settings.MAX_CHECKOUT_LINE_QUANTITY
+    assert variant_data["byAddress"] == settings.MAX_CHECKOUT_LINE_QUANTITY
diff --git a/saleor/graphql/product/tests/test_variant_pricing.py b/saleor/graphql/product/tests/test_variant_pricing.py
index f800f297293..b9d29bb7d81 100644
--- a/saleor/graphql/product/tests/test_variant_pricing.py
+++ b/saleor/graphql/product/tests/test_variant_pricing.py
@@ -8,12 +8,12 @@
 from ...tests.utils import get_graphql_content
 
 QUERY_GET_VARIANT_PRICING = """
-query ($channel: String) {
+query ($channel: String, $address: AddressInput) {
   products(first: 1, channel: $channel) {
     edges {
       node {
         variants {
-          pricing {
+          pricing(address: $address) {
             onSale
             discount {
               currency
@@ -47,9 +47,8 @@ def test_get_variant_pricing_on_sale(api_client, sale, product, channel_USD):
     sale_discounted_value = sale.channel_listings.get().discount_value
     discounted_price = price.amount - sale_discounted_value
 
-    response = api_client.post_graphql(
-        QUERY_GET_VARIANT_PRICING, {"channel": channel_USD.slug}
-    )
+    variables = {"channel": channel_USD.slug, "address": {"country": "US"}}
+    response = api_client.post_graphql(QUERY_GET_VARIANT_PRICING, variables)
     content = get_graphql_content(response)
 
     pricing = content["data"]["products"]["edges"][0]["node"]["variants"][0]["pricing"]
@@ -76,9 +75,8 @@ def test_get_variant_pricing_on_sale(api_client, sale, product, channel_USD):
 def test_get_variant_pricing_not_on_sale(api_client, product, channel_USD):
     price = product.variants.first().channel_listings.get().price
 
-    response = api_client.post_graphql(
-        QUERY_GET_VARIANT_PRICING, {"channel": channel_USD.slug}
-    )
+    variables = {"channel": channel_USD.slug, "address": {"country": "US"}}
+    response = api_client.post_graphql(QUERY_GET_VARIANT_PRICING, variables)
     content = get_graphql_content(response)
 
     pricing = content["data"]["products"]["edges"][0]["node"]["variants"][0]["pricing"]
diff --git a/saleor/graphql/product/types/channels.py b/saleor/graphql/product/types/channels.py
index e1a84d671ad..70c3a2ac20e 100644
--- a/saleor/graphql/product/types/channels.py
+++ b/saleor/graphql/product/types/channels.py
@@ -12,10 +12,12 @@
     get_margin_for_variant_channel_listing,
     get_product_costs_data,
 )
+from ...account import types as account_types
 from ...channel.dataloaders import ChannelByIdLoader
 from ...core.connection import CountableDjangoObjectType
 from ...decorators import permission_required
 from ...discount.dataloaders import DiscountsByDateTimeLoader
+from ...utils import get_user_country_context
 from ..dataloaders import (
     CollectionsByProductIdLoader,
     ProductVariantsByProductIdLoader,
@@ -40,6 +42,15 @@ class ProductChannelListing(CountableDjangoObjectType):
     )
     pricing = graphene.Field(
         "saleor.graphql.product.types.products.ProductPricingInfo",
+        address=graphene.Argument(
+            account_types.AddressInput,
+            description=(
+                "Destination address used to find warehouses where stock availability "
+                "for this product is checked. If address is empty, uses "
+                "`Shop.companyAddress` or fallbacks to server's "
+                "`settings.DEFAULT_COUNTRY` configuration."
+            ),
+        ),
         description=(
             "Lists the storefront product's pricing, the current price and discounts, "
             "only meant for displaying."
@@ -142,8 +153,11 @@ def resolve_is_available_for_purchase(root: models.ProductChannelListing, _info)
         return root.is_available_for_purchase()
 
     @staticmethod
-    def resolve_pricing(root: models.ProductChannelListing, info):
+    def resolve_pricing(root: models.ProductChannelListing, info, address=None):
         context = info.context
+        country_code = get_user_country_context(
+            address, info.context.site.settings.company_address
+        )
 
         def calculate_pricing_info(discounts):
             def calculate_pricing_with_channel(channel):
@@ -163,7 +177,7 @@ def calculate_pricing_with_collections(collections):
                                     collections=collections,
                                     discounts=discounts,
                                     channel=channel,
-                                    country=context.country,
+                                    country=country_code,
                                     local_currency=context.currency,
                                     plugins=context.plugins,
                                 )
diff --git a/saleor/graphql/product/types/products.py b/saleor/graphql/product/types/products.py
index 99edb98c0e9..89f30771ef1 100644
--- a/saleor/graphql/product/types/products.py
+++ b/saleor/graphql/product/types/products.py
@@ -23,6 +23,7 @@
 )
 from ....product.utils.variants import get_variant_selection_attributes
 from ....warehouse.availability import is_product_in_stock
+from ...account import types as account_types
 from ...account.enums import CountryCodeEnum
 from ...attribute.filters import AttributeFilterInput
 from ...attribute.resolvers import resolve_attributes
@@ -57,7 +58,11 @@
     ProductTranslation,
     ProductVariantTranslation,
 )
-from ...utils import get_database_id, get_user_or_app_from_context
+from ...utils import (
+    get_database_id,
+    get_user_country_context,
+    get_user_or_app_from_context,
+)
 from ...utils.filters import reporting_period_to_date
 from ...warehouse.dataloaders import (
     AvailableQuantityByProductVariantIdAndCountryCodeLoader,
@@ -94,6 +99,16 @@
 )
 from .digital_contents import DigitalContent
 
+destination_address_argument = graphene.Argument(
+    account_types.AddressInput,
+    description=(
+        "Destination address used to find warehouses where stock availability "
+        "for this product is checked. If address is empty, uses "
+        "`Shop.companyAddress` or fallbacks to server's "
+        "`settings.DEFAULT_COUNTRY` configuration."
+    ),
+)
+
 
 class Margin(graphene.ObjectType):
     start = graphene.Int()
@@ -157,6 +172,7 @@ class ProductVariant(ChannelContextTypeWithMetadata, CountableDjangoObjectType):
     )
     pricing = graphene.Field(
         VariantPricingInfo,
+        address=destination_address_argument,
         description=(
             "Lists the storefront variant's pricing, the current price and discounts, "
             "only meant for displaying."
@@ -197,18 +213,24 @@ class ProductVariant(ChannelContextTypeWithMetadata, CountableDjangoObjectType):
     stocks = graphene.Field(
         graphene.List(Stock),
         description="Stocks for the product variant.",
+        address=destination_address_argument,
         country_code=graphene.Argument(
             CountryCodeEnum,
-            description="Two-letter ISO 3166-1 country code.",
-            required=False,
+            description=(
+                "DEPRECATED: use `address` argument instead. This argument will be "
+                "removed in Saleor 4.0. Two-letter ISO 3166-1 country code."
+            ),
         ),
     )
     quantity_available = graphene.Int(
         required=True,
         description="Quantity of a product available for sale in one checkout.",
+        address=destination_address_argument,
         country_code=graphene.Argument(
             CountryCodeEnum,
             description=(
+                "DEPRECATED: use `address` argument instead. This argument will be "
+                "removed in Saleor 4.0."
                 "Two-letter ISO 3166-1 country code. When provided, the exact quantity "
                 "from a warehouse operating in shipping zones that contain this "
                 "country will be returned. Otherwise, it will return the maximum "
@@ -231,16 +253,32 @@ class Meta:
         [ProductPermissions.MANAGE_PRODUCTS, OrderPermissions.MANAGE_ORDERS]
     )
     def resolve_stocks(
-        root: ChannelContext[models.ProductVariant], info, country_code=None
+        root: ChannelContext[models.ProductVariant],
+        info,
+        address=None,
+        country_code=None,
     ):
+        if address is not None:
+            country_code = get_user_country_context(
+                address, info.context.site.settings.company_address
+            )
+
         if not country_code:
             return root.node.stocks.annotate_available_quantity()
         return root.node.stocks.for_country(country_code).annotate_available_quantity()
 
     @staticmethod
     def resolve_quantity_available(
-        root: ChannelContext[models.ProductVariant], info, country_code=None
+        root: ChannelContext[models.ProductVariant],
+        info,
+        address=None,
+        country_code=None,
     ):
+        if address is not None:
+            country_code = get_user_country_context(
+                address, info.context.site.settings.company_address
+            )
+
         if not root.node.track_inventory:
             return settings.MAX_CHECKOUT_LINE_QUANTITY
 
@@ -293,12 +331,19 @@ def resolve_channel_listings(
         return VariantChannelListingByVariantIdLoader(info.context).load(root.node.id)
 
     @staticmethod
-    def resolve_pricing(root: ChannelContext[models.ProductVariant], info):
+    def resolve_pricing(
+        root: ChannelContext[models.ProductVariant], info, address=None
+    ):
         if not root.channel_slug:
             return None
 
+        country_code = get_user_country_context(
+            address, info.context.site.settings.company_address
+        )
+
         channel_slug = str(root.channel_slug)
         context = info.context
+
         product = ProductByIdLoader(context).load(root.node.product_id)
         product_channel_listing = ProductChannelListingByProductIdAndChannelSlugLoader(
             context
@@ -332,7 +377,7 @@ def calculate_pricing_with_collections(collections):
                                     collections=collections,
                                     discounts=discounts,
                                     channel=channel,
-                                    country=context.country,
+                                    country=country_code,
                                     local_currency=context.currency,
                                     plugins=context.plugins,
                                 )
@@ -448,13 +493,15 @@ class Product(ChannelContextTypeWithMetadata, CountableDjangoObjectType):
     )
     pricing = graphene.Field(
         ProductPricingInfo,
+        address=destination_address_argument,
         description=(
             "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."
+        address=destination_address_argument,
+        description="Whether the product is in stock and visible or not.",
     )
     tax_type = graphene.Field(
         TaxType, description="A type of tax. Assigned by enabled tax gateway"
@@ -567,10 +614,13 @@ def resolve_url(*_args):
         return ""
 
     @staticmethod
-    def resolve_pricing(root: ChannelContext[models.Product], info):
+    def resolve_pricing(root: ChannelContext[models.Product], info, address=None):
         if not root.channel_slug:
             return None
 
+        country_code = get_user_country_context(
+            address, info.context.site.settings.company_address
+        )
         context = info.context
         channel_slug = str(root.channel_slug)
         product_channel_listing = ProductChannelListingByProductIdAndChannelSlugLoader(
@@ -603,7 +653,7 @@ def calculate_pricing_with_collections(collections):
                                     collections=collections,
                                     discounts=discounts,
                                     channel=channel,
-                                    country=context.country,
+                                    country=country_code,
                                     local_currency=context.currency,
                                     plugins=context.plugins,
                                 )
@@ -630,14 +680,17 @@ def calculate_pricing_with_collections(collections):
         )
 
     @staticmethod
-    def resolve_is_available(root: ChannelContext[models.Product], info):
+    def resolve_is_available(root: ChannelContext[models.Product], info, address=None):
         if not root.channel_slug:
             return None
+
         channel_slug = str(root.channel_slug)
+        country_code = get_user_country_context(
+            address, info.context.site.settings.company_address
+        )
 
         def calculate_is_available(product_channel_listing):
-            country = info.context.country
-            in_stock = is_product_in_stock(root.node, country)
+            in_stock = is_product_in_stock(root.node, country_code)
             is_visible = False
             if product_channel_listing:
                 is_visible = product_channel_listing.is_available_for_purchase()
diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index 6109624f92c..390e8710992 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -3609,7 +3609,6 @@ input PaymentInput {
   gateway: String!
   token: String
   amount: PositiveDecimal
-  billingAddress: AddressInput
   returnUrl: String
 }
 
@@ -3805,8 +3804,8 @@ type Product implements Node & ObjectWithMetadata {
   descriptionJson: JSONString @deprecated(reason: "Will be removed in Saleor 4.0. Use the `description` field instead.")
   url: String! @deprecated(reason: "This field will be removed after 2020-07-31.")
   thumbnail(size: Int): Image
-  pricing: ProductPricingInfo
-  isAvailable: Boolean
+  pricing(address: AddressInput): ProductPricingInfo
+  isAvailable(address: AddressInput): Boolean
   taxType: TaxType
   attributes: [SelectedAttribute!]!
   channelListings: [ProductChannelListing!]
@@ -3858,7 +3857,7 @@ type ProductChannelListing implements Node {
   purchaseCost: MoneyRange
   margin: Margin
   isAvailableForPurchase: Boolean
-  pricing: ProductPricingInfo
+  pricing(address: AddressInput): ProductPricingInfo
 }
 
 input ProductChannelListingAddInput {
@@ -4233,7 +4232,7 @@ type ProductVariant implements Node & ObjectWithMetadata {
   privateMetadata: [MetadataItem]!
   metadata: [MetadataItem]!
   channelListings: [ProductVariantChannelListing!]
-  pricing: VariantPricingInfo
+  pricing(address: AddressInput): VariantPricingInfo
   attributes(variantSelection: VariantAttributeScope): [SelectedAttribute!]!
   costPrice: Money
   margin: Int
@@ -4242,8 +4241,8 @@ type ProductVariant implements Node & ObjectWithMetadata {
   images: [ProductImage]
   translation(languageCode: LanguageCodeEnum!): ProductVariantTranslation
   digitalContent: DigitalContent
-  stocks(countryCode: CountryCode): [Stock]
-  quantityAvailable(countryCode: CountryCode): Int!
+  stocks(address: AddressInput, countryCode: CountryCode): [Stock]
+  quantityAvailable(address: AddressInput, countryCode: CountryCode): Int!
 }
 
 type ProductVariantBulkCreate {
diff --git a/saleor/graphql/tests/test_utils.py b/saleor/graphql/tests/test_utils.py
new file mode 100644
index 00000000000..a977a19d478
--- /dev/null
+++ b/saleor/graphql/tests/test_utils.py
@@ -0,0 +1,22 @@
+from django.conf import settings
+
+from ..utils import get_user_country_context
+
+
+def test_get_user_country_context(address, address_other_country):
+    destination_address = address
+    company_address = address_other_country
+
+    country = get_user_country_context(destination_address=destination_address)
+    assert country == destination_address.country.code
+
+    country = get_user_country_context(company_address=company_address)
+    assert country == company_address.country.code
+
+    country = get_user_country_context(
+        destination_address=destination_address, company_address=company_address
+    )
+    assert country == destination_address.country.code
+
+    country = get_user_country_context()
+    assert country == settings.DEFAULT_COUNTRY
diff --git a/saleor/graphql/utils/__init__.py b/saleor/graphql/utils/__init__.py
index 8b5ad8e5c26..a894f19c502 100644
--- a/saleor/graphql/utils/__init__.py
+++ b/saleor/graphql/utils/__init__.py
@@ -1,8 +1,10 @@
-from typing import Union
+from typing import TYPE_CHECKING, Optional, Union
 
 import graphene
+from django.conf import settings
 from django.db.models import Value
 from django.db.models.functions import Concat
+from django_countries.fields import Country
 from graphene_django.registry import get_global_registry
 from graphql.error import GraphQLError
 from graphql_relay import from_global_id
@@ -10,6 +12,11 @@
 from ..core.enums import PermissionEnum
 from ..core.types import Permission
 
+if TYPE_CHECKING:
+    from ...account import models as account_models
+    from ..account import types as account_types
+
+
 ERROR_COULD_NO_RESOLVE_GLOBAL_ID = (
     "Could not resolve to a node with the global id list of '%s'."
 )
@@ -131,3 +138,28 @@ def get_user_or_app_from_context(context):
 def requestor_is_superuser(requestor):
     """Return True if requestor is superuser."""
     return getattr(requestor, "is_superuser", False)
+
+
+def get_user_country_context(
+    destination_address: Optional[
+        Union["account_types.AddressInput", "account_models.Address"]
+    ] = None,
+    company_address: Optional["account_models.Address"] = None,
+) -> str:
+    """Get country of the current user to use for tax and stock related calculations.
+
+    User's country context is determined from the provided `destination_address` (which
+    may represent user's current location determined by the client or a shipping
+    address provided in the checkout). If `destination_address` is not given, the
+    default company address from the shop settings is assumed. If this address is not
+    set, fallback to the `DEFAULT_COUNTRY` setting.
+    """
+    if destination_address and destination_address.country:
+        if isinstance(destination_address, Country):
+            return destination_address.country.code
+        else:
+            return destination_address.country
+    elif company_address and company_address.country:
+        return company_address.country
+    else:
+        return settings.DEFAULT_COUNTRY
diff --git a/saleor/product/utils/availability.py b/saleor/product/utils/availability.py
index a26c4cd6f31..97294cb896e 100644
--- a/saleor/product/utils/availability.py
+++ b/saleor/product/utils/availability.py
@@ -2,7 +2,6 @@
 from typing import TYPE_CHECKING, Iterable, List, Optional, Tuple, Union
 
 import opentracing
-from django.conf import settings
 from prices import MoneyRange, TaxedMoney, TaxedMoneyRange
 
 from ...channel.models import Channel
@@ -257,9 +256,6 @@ def get_variant_availability(
 
         discount = _get_total_discount(undiscounted, discounted)
 
-        if country is None:
-            country = settings.DEFAULT_COUNTRY
-
         if local_currency:
             price_local_currency = to_local_currency(discounted, local_currency)
             discount_local_currency = to_local_currency(discount, local_currency)
diff --git a/saleor/settings.py b/saleor/settings.py
index 042ee60dd30..4f685fc1865 100644
--- a/saleor/settings.py
+++ b/saleor/settings.py
@@ -213,7 +213,6 @@ def get_bool_from_env(name, default_value):
     "saleor.core.middleware.request_time",
     "saleor.core.middleware.discounts",
     "saleor.core.middleware.google_analytics",
-    "saleor.core.middleware.country",
     "saleor.core.middleware.currency",
     "saleor.core.middleware.site",
     "saleor.core.middleware.plugins",


Code review comments:
diff_hunk: @@ -131,3 +138,28 @@ def get_user_or_app_from_context(context):
 def requestor_is_superuser(requestor):
     """Return True if requestor is superuser."""
     return getattr(requestor, "is_superuser", False)
+
+
+def get_user_country_context(
+    destination_address: Optional[
+        Union["account_types.AddressInput", "account_models.Address"]
+    ] = None,
+    company_address: Optional["account_models.Address"] = None,
+) -> str:
+    """Get country of the current user to use for tax and stock related calculations.
+
+    User's country context is determined from the provided `destination_address` (which
+    may represent user's current location determined by the client or a shipping
+    address provided in the checkout). If `destination_address` is not given, the
+    default company address from the shop settings is assumed. If this address is not
+    set, fallback to the `DEFAULT_COUNTRY` setting.
+    """
+    if destination_address and destination_address.country:
+        if isinstance(destination_address, Country):
path: saleor/graphql/utils/__init__.py
text: @user1:
It shouldn't be like this?
```suggestion
        if isinstance(destination_address.country, Country):
```

@author:
Yep, it's a bug.