Repository: saleor/saleor
Pull request: Add backwards compatibility for taxCode field
Fixes #12321 

Acceptance criteria:
- [ ] Providing `taxCode` field should create a tax class and assign it to the product type or product. When the tax class doesn't exist, it should be created automatically (the next checkpoint describes matching criteria). This should work for the following mutations:
  - [ ] `productTypeCreate`
  - [ ] `productTypeUpdate`
  - [ ] `productCreate`
  - [ ] `productUpdate`
- [ ] Existing tax class should be matched with the code by:
  - [ ] tax class name; Example: there is a tax class with name "P0000000" in the system and provided `taxCode` is "P0000000" - the tax class should be found and reused (no new tax classes are created).
  - [ ] tax class metadata key "avatax.code"; Example: there is a tax class with any name and a metadata key: "avatax.code": "P0000000"; then provided `taxCode` is "P0000000", it should find the tax class by this metadata item, and it should be assigned (no new tax classes are created)
- [ ] When there are no tax classes in the system, providing any `taxCode` should create a tax class with the tax code set as tax class name
- [ ] When Avalara is enabled and no tax classes exist, providing any `taxCode` should create tax class with a name as above, but also with the "avatax.code" metadata set.

# Impact

* [ ] New migrations
* [ ] New/Updated API fields or mutations
* [ ] Deprecated API fields or mutations
* [ ] Removed API types, fields, or mutations
* [ ] Documentation needs to be updated

# Pull Request Checklist

<!-- Please keep this section. It will make the maintainer's life easier. -->

* [ ] Privileged queries and mutations are guarded by proper permission checks
* [ ] Database queries are optimized and the number of queries is constant
* [ ] Database migration files are up to date
* [ ] The changes are tested
* [ ] GraphQL schema and type definitions are up to date
* [ ] Changes are mentioned in the changelog


Problem statement:
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

Hints from issue discussion:

Commit message: Adjust test

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f70a2b4ada..f9765ea1af4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ All notable, unreleased changes to this project will be documented in this file.
   - Add new optional field `Manifest.requiredSaleorVersion`
   - Add `requiredSaleorVersion` validation to `appInstall` and `appFetchManifest` mutations
 - Add new field `author` to `Manifest` and `App` object types - #12166 by @przlada
+- Add backwards compatibility for `taxCode` field - #12325 by @maarcingebala
 
 ### Other changes
 - Create order discounts for all voucher types - #12272 by @IKarbowiak
diff --git a/saleor/graphql/product/mutations/product/product_create.py b/saleor/graphql/product/mutations/product/product_create.py
index 0a29244c6d0..4cb9b8daa83 100644
--- 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
index 1622f9eb6e4..d8f9606b945 100644
--- 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
index 00000000000..0dd81fd78c4
--- /dev/null
+++ b/saleor/graphql/product/mutations/utils.py
@@ -0,0 +1,29 @@
+from django.db.models import Q
+
+from ....plugins.manager import PluginsManager
+from ....tax.models import TaxClass
+
+"""
+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.
+"""
+
+
+def clean_tax_code(cleaned_input: dict, manager: PluginsManager):
+    tax_code = cleaned_input.get("tax_code")
+    if tax_code and "tax_class" not in cleaned_input:
+        tax_code = cleaned_input["tax_code"]
+        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
diff --git a/saleor/graphql/product/tests/deprecated/test_utils.py b/saleor/graphql/product/tests/deprecated/test_utils.py
new file mode 100644
index 00000000000..2281409c7cb
--- /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
index 5b8d37cb175..61d7eeec0e4 100644
--- 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/graphql/schema.graphql b/saleor/graphql/schema.graphql
index a3a52d26f73..7adb9ba2210 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -18158,7 +18158,7 @@ input ProductCreateInput {
   """
   Tax rate for enabled tax gateway. 
   
-  DEPRECATED: this field will be removed in Saleor 4.0. Use tax classes to control the tax calculation for a product.
+  DEPRECATED: this field will be removed in Saleor 4.0. 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.
   """
   taxCode: String
 
@@ -18345,7 +18345,7 @@ input ProductInput {
   """
   Tax rate for enabled tax gateway. 
   
-  DEPRECATED: this field will be removed in Saleor 4.0. Use tax classes to control the tax calculation for a product.
+  DEPRECATED: this field will be removed in Saleor 4.0. 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.
   """
   taxCode: String
 
@@ -18632,7 +18632,7 @@ input ProductTypeInput {
   """
   Tax rate for enabled tax gateway. 
   
-  DEPRECATED: this field will be removed in Saleor 4.0.. Use tax classes to control the tax calculation for a product type.
+  DEPRECATED: this field will be removed in Saleor 4.0.. 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.
   """
   taxCode: String
 
diff --git a/saleor/plugins/avatax/tests/deprecated/__init__.py b/saleor/plugins/avatax/tests/deprecated/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
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
index 00000000000..4922a0e8acb
--- /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)


Code review comments:
diff_hunk: @@ -0,0 +1,29 @@
+from django.db.models import Q
+
+from ....plugins.manager import PluginsManager
+from ....tax.models import TaxClass
+
+"""
+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.
+"""
+
+
+def clean_tax_code(cleaned_input: dict, manager: PluginsManager):
+    tax_code = cleaned_input.get("tax_code")
+    if tax_code and "tax_class" not in cleaned_input:
+        tax_code = cleaned_input["tax_code"]
path: saleor/graphql/product/mutations/utils.py
text: @user1:
You already have `tax_code` from line 17.