Repository: saleor/saleor

Issue:
Bug: taxCode field is not respected when creating a product or product type
### What are you trying to achieve?

I'm trying to create a product type and provide a tax code using the old deprecated `taxCode` field. The same issue happens for `productTypeUpdate`, `productCreate` and `productUpdate` mutations.

### Steps to reproduce the problem

1. Run the following mutation:
```
mutation {
  productTypeCreate(input: {name: "New tax class", taxCode: "P0000000"}) {
    errors {
      field
      message
    }
    productType {
      id
      slug
      taxClass {
        id
      }
    }
  }
}
```
2. The product type is created by the `taxCode` is ignored and it's not assigned anywhere.

### What did you expect to happen?

Providing the `taxCode` should look for matching tax classes and assign it to the product type. 

### Logs

_No response_

### Environment

Saleor version: 3.9 and higher



Discussion:

Resolving patch:
diff --git a/saleor/graphql/product/mutations/product/product_create.py b/saleor/graphql/product/mutations/product/product_create.py
--- a/saleor/graphql/product/mutations/product/product_create.py
+++ b/saleor/graphql/product/mutations/product/product_create.py
@@ -28,6 +28,7 @@
 from ....meta.mutations import MetadataInput
 from ....plugins.dataloaders import get_plugin_manager_promise
 from ...types import Product
+from ..utils import clean_tax_code
 
 
 class ProductInput(graphene.InputObjectType):
@@ -58,7 +59,10 @@ class ProductInput(graphene.InputObjectType):
     tax_code = graphene.String(
         description=(
             f"Tax rate for enabled tax gateway. {DEPRECATED_IN_3X_INPUT} "
-            "Use tax classes to control the tax calculation for a product."
+            "Use tax classes to control the tax calculation for a product. "
+            "If taxCode is provided, Saleor will try to find a tax class with given "
+            "code (codes are stored in metadata) and assign it. If no tax class is "
+            "found, it would be created and assigned."
         )
     )
     seo = SeoInput(description="Search engine optimization fields.")
@@ -178,6 +182,9 @@ def clean_input(cls, info: ResolveInfo, instance, data, **kwargs):
             except ValidationError as exc:
                 raise ValidationError({"attributes": exc})
 
+        manager = get_plugin_manager_promise(info.context).get()
+        clean_tax_code(cleaned_input, manager)
+
         clean_seo_fields(cleaned_input)
         return cleaned_input
 
diff --git a/saleor/graphql/product/mutations/product_type/product_type_create.py b/saleor/graphql/product/mutations/product_type/product_type_create.py
--- a/saleor/graphql/product/mutations/product_type/product_type_create.py
+++ b/saleor/graphql/product/mutations/product_type/product_type_create.py
@@ -11,8 +11,10 @@
 from ....core.scalars import WeightScalar
 from ....core.types import NonNullList, ProductError
 from ....core.validators import validate_slug_and_generate_if_needed
+from ....plugins.dataloaders import get_plugin_manager_promise
 from ...enums import ProductTypeKindEnum
 from ...types import ProductType
+from ..utils import clean_tax_code
 
 
 class ProductTypeInput(graphene.InputObjectType):
@@ -49,7 +51,10 @@ class ProductTypeInput(graphene.InputObjectType):
     tax_code = graphene.String(
         description=(
             f"Tax rate for enabled tax gateway. {DEPRECATED_IN_3X_INPUT}. "
-            "Use tax classes to control the tax calculation for a product type."
+            "Use tax classes to control the tax calculation for a product type. "
+            "If taxCode is provided, Saleor will try to find a tax class with given "
+            "code (codes are stored in metadata) and assign it. If no tax class is "
+            "found, it would be created and assigned."
         )
     )
     tax_class = graphene.ID(
@@ -108,8 +113,10 @@ def clean_input(cls, info: ResolveInfo, instance, data, **kwargs):
             error.code = ProductErrorCode.REQUIRED.value
             raise ValidationError({"slug": error})
 
-        cls.validate_attributes(cleaned_input)
+        manager = get_plugin_manager_promise(info.context).get()
+        clean_tax_code(cleaned_input, manager)
 
+        cls.validate_attributes(cleaned_input)
         return cleaned_input
 
     @classmethod
diff --git a/saleor/graphql/product/mutations/utils.py b/saleor/graphql/product/mutations/utils.py
new file mode 100644
--- /dev/null
+++ b/saleor/graphql/product/mutations/utils.py
@@ -0,0 +1,26 @@
+from django.db.models import Q
+
+from ....plugins.manager import PluginsManager
+from ....tax.models import TaxClass
+
+
+def clean_tax_code(cleaned_input: dict, manager: PluginsManager):
+    """Clean deprecated `taxCode` field.
+
+    This function provides backwards compatibility for the `taxCode` input field. If the
+    `taxClass` is not provided but the `taxCode` is, try to find a tax class with given
+    tax code and assign it to the product type. If no matching tax class is found,
+    create one with the given tax code.
+    """
+    tax_code = cleaned_input.get("tax_code")
+    if tax_code and "tax_class" not in cleaned_input:
+        tax_class = TaxClass.objects.filter(
+            Q(name=tax_code)
+            | Q(metadata__contains={"avatax.code": tax_code})
+            | Q(metadata__contains={"vatlayer.code": tax_code})
+        ).first()
+        if not tax_class:
+            tax_class = TaxClass.objects.create(name=tax_code)
+            manager.assign_tax_code_to_object_meta(tax_class, tax_code)
+            tax_class.save(update_fields=["metadata"])
+        cleaned_input["tax_class"] = tax_class


Test patch:
diff --git a/saleor/graphql/product/tests/deprecated/test_utils.py b/saleor/graphql/product/tests/deprecated/test_utils.py
new file mode 100644
--- /dev/null
+++ b/saleor/graphql/product/tests/deprecated/test_utils.py
@@ -0,0 +1,87 @@
+from .....plugins.manager import get_plugins_manager
+from .....tax.models import TaxClass
+from ...mutations.utils import clean_tax_code
+
+
+def test_clean_tax_code_does_nothing_when_empty_data():
+    # given
+    manager = get_plugins_manager()
+    data = {}
+
+    # when
+    clean_tax_code(data, manager)
+
+    # then
+    assert data == {}
+
+
+def test_clean_tax_code_does_nothing_when_tax_class_provided():
+    # given
+    manager = get_plugins_manager()
+    data = {"tax_code": "P0000000", "tax_class": "VGF4Q2xhc3M6MQ=="}
+
+    # when
+    clean_tax_code(data, manager)
+
+    # then
+    assert data == {"tax_code": "P0000000", "tax_class": "VGF4Q2xhc3M6MQ=="}
+
+
+def test_clean_tax_code_when_tax_class_exists_by_name():
+    # given
+    manager = get_plugins_manager()
+    tax_code = "P0000000"
+    tax_class = TaxClass.objects.create(name=tax_code)
+    data = {"tax_code": tax_code}
+
+    # when
+    clean_tax_code(data, manager)
+
+    # then
+    assert data["tax_class"] == tax_class
+
+
+def test_clean_tax_code_when_tax_class_exists_by_avatax():
+    # given
+    manager = get_plugins_manager()
+    tax_code = "P0000000"
+    tax_class = TaxClass.objects.create(name="Test", metadata={"avatax.code": tax_code})
+    data = {"tax_code": tax_code}
+
+    # when
+    clean_tax_code(data, manager)
+
+    # then
+    assert data["tax_class"] == tax_class
+
+
+def test_clean_tax_code_when_tax_class_exists_by_vatlayer():
+    # given
+    manager = get_plugins_manager()
+    tax_code = "P0000000"
+    tax_class = TaxClass.objects.create(
+        name="Test", metadata={"vatlayer.code": tax_code}
+    )
+    data = {"tax_code": tax_code}
+
+    # when
+    clean_tax_code(data, manager)
+
+    # then
+    assert data["tax_class"] == tax_class
+
+
+def test_clean_tax_code_when_tax_class_does_not_exists():
+    # given
+    manager = get_plugins_manager()
+    tax_code = "P0000000"
+    TaxClass.objects.all().delete()
+    data = {"tax_code": tax_code}
+
+    # when
+    clean_tax_code(data, manager)
+
+    # then
+    assert TaxClass.objects.count() == 1
+    tax_class = TaxClass.objects.first()
+    assert data["tax_class"] == tax_class
diff --git a/saleor/graphql/product/tests/mutations/test_product_update.py b/saleor/graphql/product/tests/mutations/test_product_update.py
--- a/saleor/graphql/product/tests/mutations/test_product_update.py
+++ b/saleor/graphql/product/tests/mutations/test_product_update.py
@@ -134,7 +134,6 @@ def test_update_product(
             "slug": product_slug,
             "description": other_description_json,
             "chargeTaxes": product_charge_taxes,
-            "taxCode": product_tax_rate,
             "attributes": [{"id": attribute_id, "values": [attr_value]}],
             "metadata": [{"key": metadata_key, "value": metadata_value}],
             "privateMetadata": [{"key": metadata_key, "value": metadata_value}],
diff --git a/saleor/plugins/avatax/tests/deprecated/__init__.py b/saleor/plugins/avatax/tests/deprecated/__init__.py
new file mode 100644
diff --git a/saleor/plugins/avatax/tests/deprecated/test_tax_code_mutations.py b/saleor/plugins/avatax/tests/deprecated/test_tax_code_mutations.py
new file mode 100644
--- /dev/null
+++ b/saleor/plugins/avatax/tests/deprecated/test_tax_code_mutations.py
@@ -0,0 +1,259 @@
+import graphene
+from django.test import override_settings
+
+from .....graphql.tests.utils import get_graphql_content
+from .....tax.models import TaxClass
+
+PRODUCT_TYPE_CREATE_MUTATION_TAX_CODE = """
+  mutation createProductType($name: String, $taxCode: String) {
+    productTypeCreate(input: {name: $name, taxCode: $taxCode}) {
+      productType {
+        name
+        slug
+        taxClass {
+          id
+          name
+          metadata {
+            key
+            value
+          }
+        }
+      }
+      errors {
+        field
+        message
+        code
+      }
+    }
+  }
+"""
+
+
+@override_settings(PLUGINS=["saleor.plugins.avatax.plugin.AvataxPlugin"])
+def test_product_type_create_tax_code_creates_new_tax_class(
+    staff_api_client,
+    permission_manage_product_types_and_attributes,
+    plugin_configuration,
+    monkeypatch,
+):
+    # given
+    plugin_configuration()
+    tax_code = "P0000000"
+    monkeypatch.setattr(
+        "saleor.plugins.avatax.plugin.get_cached_tax_codes_or_fetch",
+        lambda _: {tax_code: "desc"},
+    )
+    variables = {"name": "New product type", "taxCode": tax_code}
+    TaxClass.objects.all().delete()
+
+    # when
+    response = staff_api_client.post_graphql(
+        PRODUCT_TYPE_CREATE_MUTATION_TAX_CODE,
+        variables,
+        permissions=[permission_manage_product_types_and_attributes],
+    )
+
+    # then
+    content = get_graphql_content(response)
+    tax_class = TaxClass.objects.first()
+    assert tax_class
+    assert tax_class.name == tax_code
+    assert tax_class.metadata
+    assert tax_class.metadata["avatax.code"] == tax_code
+    assert content["data"]["productTypeCreate"]["productType"]["taxClass"][
+        "id"
+    ] == graphene.Node.to_global_id("TaxClass", tax_class.pk)
+
+
+PRODUCT_TYPE_UPDATE_MUTATION_TAX_CODE = """
+  mutation updateProductType($id: ID!, $taxCode: String) {
+    productTypeUpdate(id: $id, input: {taxCode: $taxCode}) {
+      productType {
+        name
+        slug
+        taxClass {
+          id
+          name
+          metadata {
+            key
+            value
+          }
+        }
+      }
+      errors {
+        field
+        message
+        code
+      }
+    }
+  }
+"""
+
+
+@override_settings(PLUGINS=["saleor.plugins.avatax.plugin.AvataxPlugin"])
+def test_product_type_update_tax_code_creates_new_tax_class(
+    staff_api_client,
+    product_type,
+    permission_manage_product_types_and_attributes,
+    plugin_configuration,
+    monkeypatch,
+):
+    # given
+    plugin_configuration()
+    tax_code = "P0000000"
+    monkeypatch.setattr(
+        "saleor.plugins.avatax.plugin.get_cached_tax_codes_or_fetch",
+        lambda _: {tax_code: "desc"},
+    )
+    variables = {
+        "id": graphene.Node.to_global_id("ProductType", product_type.pk),
+        "taxCode": tax_code,
+    }
+
+    # when
+    response = staff_api_client.post_graphql(
+        PRODUCT_TYPE_UPDATE_MUTATION_TAX_CODE,
+        variables,
+        permissions=[permission_manage_product_types_and_attributes],
+    )
+
+    # then
+    content = get_graphql_content(response)
+    product_type.refresh_from_db()
+    tax_class = product_type.tax_class
+    assert tax_class
+    assert tax_class.metadata["avatax.code"] == tax_code
+    assert content["data"]["productTypeUpdate"]["productType"]["taxClass"][
+        "id"
+    ] == graphene.Node.to_global_id("TaxClass", tax_class.pk)
+
+
+PRODUCT_CREATE_MUTATION_TAX_CODE = """
+  mutation ProductCreate($name: String!, $productTypeId: ID!, $taxCode: String) {
+    productCreate(
+      input: {name: $name, productType: $productTypeId, taxCode: $taxCode}
+    ) {
+      errors {
+        field
+        message
+      }
+      product {
+        id
+        name
+        taxClass {
+          id
+          name
+          metadata {
+            key
+            value
+          }
+        }
+      }
+    }
+  }
+"""
+
+
+@override_settings(PLUGINS=["saleor.plugins.avatax.plugin.AvataxPlugin"])
+def test_product_create_tax_code_creates_new_tax_class(
+    staff_api_client,
+    product_type,
+    permission_manage_products,
+    plugin_configuration,
+    monkeypatch,
+):
+    # given
+    plugin_configuration()
+    tax_code = "P0000000"
+    monkeypatch.setattr(
+        "saleor.plugins.avatax.plugin.get_cached_tax_codes_or_fetch",
+        lambda _: {tax_code: "desc"},
+    )
+    variables = {
+        "name": "New product",
+        "productTypeId": graphene.Node.to_global_id("ProductType", product_type.pk),
+        "taxCode": tax_code,
+    }
+    TaxClass.objects.all().delete()
+
+    # when
+    response = staff_api_client.post_graphql(
+        PRODUCT_CREATE_MUTATION_TAX_CODE,
+        variables,
+        permissions=[permission_manage_products],
+    )
+
+    # then
+    content = get_graphql_content(response)
+    tax_class = TaxClass.objects.first()
+    assert tax_class
+    assert tax_class.name == tax_code
+    assert tax_class.metadata
+    assert tax_class.metadata["avatax.code"] == tax_code
+    assert content["data"]["productCreate"]["product"]["taxClass"][
+        "id"
+    ] == graphene.Node.to_global_id("TaxClass", tax_class.pk)
+
+
+PRODUCT_UPDATE_MUTATION_TAX_CODE = """
+  mutation ProductUpdate($id: ID!, $taxCode: String) {
+    productUpdate(id: $id, input: {taxCode: $taxCode}) {
+      errors {
+        field
+        message
+      }
+      product {
+        id
+        name
+        taxClass {
+          id
+          name
+          metadata {
+            key
+            value
+          }
+        }
+      }
+    }
+  }
+"""
+
+
+@override_settings(PLUGINS=["saleor.plugins.avatax.plugin.AvataxPlugin"])
+def test_product_update_tax_code_creates_new_tax_class(
+    staff_api_client,
+    permission_manage_products,
+    product,
+    plugin_configuration,
+    monkeypatch,
+):
+    # given
+    plugin_configuration()
+    tax_code = "P0000000"
+    monkeypatch.setattr(
+        "saleor.plugins.avatax.plugin.get_cached_tax_codes_or_fetch",
+        lambda _: {tax_code: "desc"},
+    )
+    variables = {
+        "id": graphene.Node.to_global_id("Product", product.pk),
+        "taxCode": tax_code,
+    }
+
+    # when
+    response = staff_api_client.post_graphql(
+        PRODUCT_UPDATE_MUTATION_TAX_CODE,
+        variables,
+        permissions=[permission_manage_products],
+    )
+
+    # then
+    content = get_graphql_content(response)
+    product.refresh_from_db()
+    tax_class = product.tax_class
+    assert tax_class
+    assert tax_class.name == tax_code
+    assert tax_class.metadata
+    assert tax_class.metadata["avatax.code"] == tax_code
+    assert content["data"]["productUpdate"]["product"]["taxClass"][
+        "id"
+    ] == graphene.Node.to_global_id("TaxClass", tax_class.pk)