Repository: saleor/saleor
Pull request: Allow to add a gift card to a checkout without email
I want to merge this change because it allows user to use a gift card code on a checkout without an email address.
Wider context in issue https://github.com/saleor/saleor/issues/12997
Resolves https://github.com/saleor/saleor/issues/12997

<!-- Please mention all relevant issue numbers. -->

# 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 either absent or guarded by proper permission checks
* [ ] Database queries are optimized and the number of queries is constant
* [ ] Database migrations are either absent or optimized for zero downtime
* [ ] The changes are covered by test cases


Problem statement:
Allowing use of gift cards without checkout email
## Problem

Currently, to use a gift card code during checkout, the checkout instance must have an assigned email. If the email is not set, a validation error is returned when adding a gift card code. This requirement is in place to ensure that the provided gift card is not owned by someone else. However, requiring an email when adding a gift card code makes the checkout UI less flexible, as the email could be set later.


## 1. General Assumptions

The proposed solution is to entirely allow using gift cards regardless of who the owner is. As long as the checkout user knows the gift card code, they should be able to use it.

- Drop email validation `add_gift_card_code_to_checkout` function, drop `used_by_email` conditions.
- Calculate the discount amount after the gift card code is added.
- Deprecate all `used_by_email` and `used_by` fields in API.
    - Mark as to be dropped in Saleor 4.0.
    - Keep resolvers and logic that sets these fields, but ensure that any logic that might limit access to a gift card using these fields is removed.
- Describe the change in the changelog as a breaking change since it’s a breaking behavior - users might have relied on gift cards being limited to user emails, but now it will be possible to use them by anyone knowing the code. Update the documentation respectively.

### Acceptance Criteria

- [x] Users can add a gift card without the owner to checkout without an email set.
- [x] Users can add a gift card without the owner to checkout with an email set.
- [x] Users can add a gift card owned by another customer (with the `usedByEmail` field set) to checkout without email.
- [x] Users can add a gift card owned by another customer (with the `usedByEmail` field set) to checkout with an email.
- [x] In all above cases, discount amount is calculated as expected and user can complete checkout without any errors.

Hints from issue discussion:

Commit message: Drop rules of user validation on gift cards and fix tests accordingly

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05613783bf8..d5f1468b40c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ All notable, unreleased changes to this project will be documented in this file.
 ### Breaking changes
 
 - `path` field for errors related with product variants input in `ProductBulkCreate` will return more detailed paths: `variants.1.stocks.0.warehouse` instead of `variants.1.warehouses` - #12534 by @SzymJ
+- Gift cards can now be applied on a checkout without an email, fields `used_by` and `used_by_email` on GiftCard model are deprecated and will be removed in 4.0. - #13019 by @tomaszszymanski129
 - The `discounts` field has been removed from the listed plugin manager methods. Instead of the `discounts` argument, an applied `discount` is now assigned to each line in the `CheckoutLineInfo` inside the `CheckoutInfo` object. - #11934 by @fowczarek
   - `calculate_checkout_total`
   - `calculate_checkout_subtotal`
diff --git a/saleor/giftcard/tests/test_utils.py b/saleor/giftcard/tests/test_utils.py
index 51fd3fb8a5c..fd7b76c6204 100644
--- a/saleor/giftcard/tests/test_utils.py
+++ b/saleor/giftcard/tests/test_utils.py
@@ -49,6 +49,17 @@ def test_add_gift_card_code_to_checkout(checkout, gift_card):
     assert checkout.gift_cards.count() == 1
 
 
+def test_add_gift_card_code_to_checkout_without_email(checkout, gift_card):
+    # given
+    assert checkout.gift_cards.count() == 0
+
+    # when
+    add_gift_card_code_to_checkout(checkout, None, gift_card.code, gift_card.currency)
+
+    # then
+    assert checkout.gift_cards.count() == 1
+
+
 def test_add_gift_card_code_to_checkout_inactive_card(checkout, gift_card):
     # given
     gift_card.is_active = False
@@ -111,23 +122,6 @@ def test_add_gift_card_code_to_checkout_used_gift_card(checkout, gift_card_used)
     assert checkout.gift_cards.count() == 1
 
 
-def test_add_gift_card_code_to_checkout_used_gift_card_invalid_user(
-    checkout, gift_card_used
-):
-    # given
-    email = "new_user@example.com"
-    assert gift_card_used.used_by_email
-    assert gift_card_used.used_by_email != email
-    assert checkout.gift_cards.count() == 0
-
-    # when
-    # then
-    with pytest.raises(InvalidPromoCode):
-        add_gift_card_code_to_checkout(
-            checkout, email, gift_card_used.code, gift_card_used.currency
-        )
-
-
 def test_remove_gift_card_code_from_checkout(checkout, gift_card):
     # given
     checkout.gift_cards.add(gift_card)
diff --git a/saleor/giftcard/utils.py b/saleor/giftcard/utils.py
index d82483e54cb..f360830ff6a 100644
--- a/saleor/giftcard/utils.py
+++ b/saleor/giftcard/utils.py
@@ -38,10 +38,6 @@ def add_gift_card_code_to_checkout(
     Raise ValidationError if email is not provided.
     Raise InvalidPromoCode if gift card cannot be applied.
     """
-    from ..checkout.checkout_cleaner import validate_checkout_email
-
-    validate_checkout_email(checkout)
-
     try:
         # only active gift card with currency the same as channel currency can be used
         gift_card = (
@@ -52,11 +48,6 @@ def add_gift_card_code_to_checkout(
     except GiftCard.DoesNotExist:
         raise InvalidPromoCode()
 
-    used_by_email = gift_card.used_by_email
-    # gift card can be used only by one user
-    if used_by_email and used_by_email != email:
-        raise InvalidPromoCode()
-
     checkout.gift_cards.add(gift_card)
     checkout.save(update_fields=["last_change"])
 
@@ -66,8 +57,7 @@ def remove_gift_card_code_from_checkout(checkout: Checkout, gift_card_code: str)
 
     Return information whether promo code was removed.
     """
-    gift_card = checkout.gift_cards.filter(code=gift_card_code).first()
-    if gift_card:
+    if gift_card := checkout.gift_cards.filter(code=gift_card_code).first():
         checkout.gift_cards.remove(gift_card)
         checkout.save(update_fields=["last_change"])
         return True
diff --git a/saleor/graphql/checkout/tests/mutations/test_checkout_add_promo_code.py b/saleor/graphql/checkout/tests/mutations/test_checkout_add_promo_code.py
index 1a8111e6582..254e12ec439 100644
--- a/saleor/graphql/checkout/tests/mutations/test_checkout_add_promo_code.py
+++ b/saleor/graphql/checkout/tests/mutations/test_checkout_add_promo_code.py
@@ -789,28 +789,6 @@ def test_checkout_add_used_gift_card_code(
     )
 
 
-def test_checkout_add_used_gift_card_code_invalid_user(
-    staff_api_client, checkout_with_item, gift_card_used, staff_user
-):
-    # given
-    checkout_with_item.user = staff_user
-    assert gift_card_used.used_by_email != checkout_with_item.user.email
-
-    variables = {
-        "id": to_global_id_or_none(checkout_with_item),
-        "promoCode": gift_card_used.code,
-    }
-
-    # when
-    data = _mutate_checkout_add_promo_code(staff_api_client, variables)
-
-    # then
-    assert not data["checkout"]
-    assert len(data["errors"]) == 1
-    assert data["errors"][0]["code"] == CheckoutErrorCode.INVALID.name
-    assert data["errors"][0]["field"] == "promoCode"
-
-
 def test_checkout_get_total_with_gift_card(api_client, checkout_with_item, gift_card):
     manager = get_plugins_manager()
     lines, _ = fetch_checkout_lines(checkout_with_item)
@@ -1016,10 +994,31 @@ def test_checkout_add_gift_card_without_checkout_email(
     # given
     checkout_with_item.email = None
     checkout_with_item.save(update_fields=["email"])
+    gift_card_id = graphene.Node.to_global_id("GiftCard", gift_card.pk)
+    variables = {
+        "id": to_global_id_or_none(checkout_with_item),
+        "promoCode": gift_card.code,
+    }
 
-    gift_card.expiry_date = date.today() - timedelta(days=1)
-    gift_card.save()
+    # when
+    data = _mutate_checkout_add_promo_code(api_client, variables)
+
+    # then
+    assert not data["errors"]
+    assert data["checkout"]["token"] == str(checkout_with_item.token)
+    assert data["checkout"]["giftCards"][0]["id"] == gift_card_id
+    assert data["checkout"]["giftCards"][0]["last4CodeChars"] == gift_card.display_code
 
+
+def test_checkout_add_gift_card_without_checkout_email_used_by_someone_else_email(
+    api_client, checkout_with_item, gift_card
+):
+    # given
+    checkout_with_item.email = None
+    checkout_with_item.save(update_fields=["email"])
+    gift_card.used_by_email = "nonexisting@example.com"
+    gift_card.save(update_fields=["used_by_email"])
+    gift_card_id = graphene.Node.to_global_id("GiftCard", gift_card.pk)
     variables = {
         "id": to_global_id_or_none(checkout_with_item),
         "promoCode": gift_card.code,
@@ -1029,8 +1028,34 @@ def test_checkout_add_gift_card_without_checkout_email(
     data = _mutate_checkout_add_promo_code(api_client, variables)
 
     # then
-    assert data["errors"]
-    assert data["errors"][0]["code"] == CheckoutErrorCode.EMAIL_NOT_SET.name
+    assert not data["errors"]
+    assert data["checkout"]["token"] == str(checkout_with_item.token)
+    assert data["checkout"]["giftCards"][0]["id"] == gift_card_id
+    assert data["checkout"]["giftCards"][0]["last4CodeChars"] == gift_card.display_code
+
+
+def test_checkout_add_gift_card_without_checkout_email_used_by_other_user(
+    api_client, checkout_with_item, gift_card, customer_user
+):
+    # given
+    checkout_with_item.email = None
+    checkout_with_item.save(update_fields=["email"])
+    gift_card.used_by = customer_user
+    gift_card.save(update_fields=["used_by"])
+    gift_card_id = graphene.Node.to_global_id("GiftCard", gift_card.pk)
+    variables = {
+        "id": to_global_id_or_none(checkout_with_item),
+        "promoCode": gift_card.code,
+    }
+
+    # when
+    data = _mutate_checkout_add_promo_code(api_client, variables)
+
+    # then
+    assert not data["errors"]
+    assert data["checkout"]["token"] == str(checkout_with_item.token)
+    assert data["checkout"]["giftCards"][0]["id"] == gift_card_id
+    assert data["checkout"]["giftCards"][0]["last4CodeChars"] == gift_card.display_code
 
 
 @pytest.mark.parametrize("shipping_price", [12, 10, 5])
diff --git a/saleor/graphql/giftcard/types.py b/saleor/graphql/giftcard/types.py
index 88bb469b809..e701b6074b2 100644
--- a/saleor/graphql/giftcard/types.py
+++ b/saleor/graphql/giftcard/types.py
@@ -250,6 +250,7 @@ class GiftCard(ModelObjectType[models.GiftCard]):
     used_by = graphene.Field(
         "saleor.graphql.account.types.User",
         description=("The customer who used a gift card." + ADDED_IN_31),
+        deprecation_reason=DEPRECATED_IN_3X_FIELD,
     )
     created_by_email = graphene.String(
         required=False,
@@ -266,6 +267,7 @@ class GiftCard(ModelObjectType[models.GiftCard]):
         description=(
             "Email address of the customer who used a gift card." + ADDED_IN_31
         ),
+        deprecation_reason=DEPRECATED_IN_3X_FIELD,
     )
     last_used_on = graphene.DateTime()
     expiry_date = Date()
diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index 00a287ce443..d84ef4151b6 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -9633,7 +9633,7 @@ type GiftCard implements Node & ObjectWithMetadata @doc(category: "Gift cards")
   
   Added in Saleor 3.1.
   """
-  usedBy: User
+  usedBy: User @deprecated(reason: "This field will be removed in Saleor 4.0.")
 
   """
   Email address of the user who bought or issued gift card.
@@ -9649,7 +9649,7 @@ type GiftCard implements Node & ObjectWithMetadata @doc(category: "Gift cards")
   
   Added in Saleor 3.1.
   """
-  usedByEmail: String
+  usedByEmail: String @deprecated(reason: "This field will be removed in Saleor 4.0.")
   lastUsedOn: DateTime
   expiryDate: Date
 


Code review comments:
diff_hunk: @@ -7,6 +7,7 @@ All notable, unreleased changes to this project will be documented in this file.
 ### Breaking changes
 
 - `path` field for errors related with product variants input in `ProductBulkCreate` will return more detailed paths: `variants.1.stocks.0.warehouse` instead of `variants.1.warehouses` - #12534 by @SzymJ
+- Gift cards can now be applied on a checkout without an email, fields `used_by` and `used_by_email` on GiftCard model are deprecated and will be removed in 4.0. - #13019 by @tomaszszymanski129
path: CHANGELOG.md
text: @user1:
Move to 3.15 section.