Repository: saleor/saleor

Issue:
Add mutation to use voucher in checkout.
### What I'm trying to achieve
I am trying to add voucher to the cart with help of graphql

### Describe a proposed solution
Add mutation to the graphql api

### Other solutions I've tried and won't work
…

### Screenshots or mockups
...



Discussion:
Please note: voucher should be validated on every mutation that changes the Cart.

Eg. shipping voucher might be valid only on shipment to certain countries, so we should handle that on shipping address change.

Some orders can be valid but only for orders over a certain amount, so we should check if a voucher is still valid on cart's items change (add/delete/change the quantity)
Indeed I don't see any option to add a voucher to checkout in API. I guess we should add a new mutation for that purpose. E.g.:

```checkoutUpdateVoucher(checkoutId: ID!, voucherCode: String)```

where `voucherCode` is the unique code of the voucher. Passing a `null` code would result in removing the voucher from checkout.

Resolving patch:
diff --git a/saleor/checkout/utils.py b/saleor/checkout/utils.py
--- a/saleor/checkout/utils.py
+++ b/saleor/checkout/utils.py
@@ -738,6 +738,23 @@ def recalculate_cart_discount(cart, discounts, taxes):
         remove_voucher_from_cart(cart)
 
 
+def add_voucher_to_cart(voucher, cart):
+    """Add voucher data to cart.
+
+    Raise NotApplicable if voucher of given type cannot be applied."""
+    discount_amount = get_voucher_discount_for_cart(voucher, cart)
+    cart.voucher_code = voucher.code
+    cart.discount_name = voucher.name
+    cart.translated_discount_name = (
+        voucher.translated.name
+        if voucher.translated.name != voucher.name else '')
+    cart.discount_amount = discount_amount
+    cart.save(
+        update_fields=[
+            'voucher_code', 'discount_name', 'translated_discount_name',
+            'discount_amount'])
+
+
 def remove_voucher_from_cart(cart):
     """Remove voucher data from cart."""
     cart.voucher_code = None
diff --git a/saleor/graphql/checkout/mutations.py b/saleor/graphql/checkout/mutations.py
--- a/saleor/graphql/checkout/mutations.py
+++ b/saleor/graphql/checkout/mutations.py
@@ -1,14 +1,18 @@
+from datetime import date
+
 import graphene
 from django.db import transaction
 
 from ...checkout import models
 from ...checkout.utils import (
-    add_variant_to_cart, change_billing_address_in_cart,
+    add_variant_to_cart, add_voucher_to_cart, change_billing_address_in_cart,
     change_shipping_address_in_cart, create_order, get_taxes_for_cart,
-    ready_to_place_order)
+    get_voucher_for_cart, ready_to_place_order, recalculate_cart_discount,
+    remove_voucher_from_cart)
 from ...core import analytics
 from ...core.exceptions import InsufficientStock
 from ...core.utils.taxes import get_taxes_for_address
+from ...discount import models as voucher_model
 from ...payment import PaymentError
 from ...payment.utils import gateway_process_payment
 from ...shipping.models import ShippingMethod as ShippingMethodModel
@@ -234,6 +238,9 @@ def mutate(cls, root, info, checkout_id, lines, replace=False):
                 add_variant_to_cart(
                     checkout, variant, quantity, replace=replace)
 
+        recalculate_cart_discount(
+            checkout, info.context.discounts, info.context.taxes)
+
         return CheckoutLinesAdd(checkout=checkout, errors=errors)
 
 
@@ -278,6 +285,9 @@ def mutate(cls, root, info, checkout_id, line_id):
         if errors:
             return CheckoutLineDelete(errors=errors)
 
+        recalculate_cart_discount(
+            checkout, info.context.discounts, info.context.taxes)
+
         return CheckoutLineDelete(checkout=checkout, errors=errors)
 
 
@@ -362,6 +372,9 @@ def mutate(cls, root, info, checkout_id, shipping_address):
                 with transaction.atomic():
                     shipping_address.save()
                     change_shipping_address_in_cart(checkout, shipping_address)
+                recalculate_cart_discount(
+                    checkout, info.context.discounts, info.context.taxes)
+
         return CheckoutShippingAddressUpdate(checkout=checkout, errors=errors)
 
 
@@ -446,6 +459,9 @@ def mutate(cls, root, info, checkout_id, shipping_method_id):
         if not errors:
             checkout.shipping_method = shipping_method
             checkout.save(update_fields=['shipping_method'])
+            recalculate_cart_discount(
+                checkout, info.context.discounts, info.context.taxes)
+
         return CheckoutShippingMethodUpdate(checkout=checkout, errors=errors)
 
 
@@ -495,3 +511,53 @@ def mutate(cls, root, info, checkout_id):
         except PaymentError as e:
             cls.add_error(errors=errors, field=None, message=str(e))
         return CheckoutComplete(order=order, errors=errors)
+
+
+class CheckoutUpdateVoucher(BaseMutation):
+    checkout = graphene.Field(
+        Checkout, description='An checkout with updated voucher')
+
+    class Arguments:
+        checkout_id = graphene.ID(description='Checkout ID', required=True)
+        voucher_code = graphene.String(description='Voucher code')
+
+    class Meta:
+        description = (
+            'Adds voucher to the checkout. '
+            'Query it without voucher_code field to '
+            'remove voucher from checkout.')
+
+    @classmethod
+    def mutate(cls, root, info, checkout_id, voucher_code=None):
+        errors = []
+        checkout = cls.get_node_or_error(
+            info, checkout_id, errors, 'checkout_id', only_type=Checkout)
+        if checkout is None:
+            return CheckoutUpdateVoucher(errors=errors)
+
+        if voucher_code:
+            try:
+                voucher = voucher_model.Voucher.objects.active(
+                    date=date.today()).get(code=voucher_code)
+            except voucher_model.Voucher.DoesNotExist:
+                cls.add_error(
+                    errors=errors,
+                    field='voucher_code',
+                    message='Voucher with given code does not exist.')
+                return CheckoutUpdateVoucher(errors=errors)
+
+            try:
+                add_voucher_to_cart(voucher, checkout)
+            except voucher_model.NotApplicable:
+                cls.add_error(
+                    errors=errors,
+                    field='voucher_code',
+                    message='Voucher is not applicable to that checkout.')
+                return CheckoutUpdateVoucher(errors=errors)
+
+        else:
+            existing_voucher = get_voucher_for_cart(checkout)
+            if existing_voucher:
+                remove_voucher_from_cart(checkout)
+
+        return CheckoutUpdateVoucher(checkout=checkout, errors=errors)
diff --git a/saleor/graphql/checkout/schema.py b/saleor/graphql/checkout/schema.py
--- a/saleor/graphql/checkout/schema.py
+++ b/saleor/graphql/checkout/schema.py
@@ -8,7 +8,8 @@
     CheckoutBillingAddressUpdate, CheckoutComplete, CheckoutCreate,
     CheckoutCustomerAttach, CheckoutCustomerDetach, CheckoutEmailUpdate,
     CheckoutLineDelete, CheckoutLinesAdd, CheckoutLinesUpdate,
-    CheckoutShippingAddressUpdate, CheckoutShippingMethodUpdate)
+    CheckoutShippingAddressUpdate, CheckoutShippingMethodUpdate,
+    CheckoutUpdateVoucher)
 from .resolvers import (
     resolve_checkout, resolve_checkout_lines, resolve_checkouts)
 from .types import Checkout, CheckoutLine
@@ -55,3 +56,4 @@ class CheckoutMutations(graphene.ObjectType):
     checkout_payment_create = CheckoutPaymentCreate.Field()
     checkout_shipping_address_update = CheckoutShippingAddressUpdate.Field()
     checkout_shipping_method_update = CheckoutShippingMethodUpdate.Field()
+    checkout_update_voucher = CheckoutUpdateVoucher.Field()


Test patch:
diff --git a/tests/api/test_checkout.py b/tests/api/test_checkout.py
--- a/tests/api/test_checkout.py
+++ b/tests/api/test_checkout.py
@@ -3,12 +3,13 @@
 
 import graphene
 import pytest
+from tests.api.utils import get_graphql_content
 
 from saleor.checkout.models import Cart
-from saleor.checkout.utils import is_fully_paid, ready_to_place_order
+from saleor.checkout.utils import (
+    add_voucher_to_cart, is_fully_paid, ready_to_place_order)
 from saleor.graphql.core.utils import str_to_enum
 from saleor.order.models import Order
-from tests.api.utils import get_graphql_content
 
 MUTATION_CHECKOUT_CREATE = """
     mutation createCheckout($checkoutInput: CheckoutCreateInput!) {
@@ -476,35 +477,39 @@ def test_checkout_lines_update_check_lines_quantity(
     assert data['errors'][0]['field'] == 'quantity'
 
 
+MUTATION_CHECKOUT_LINES_DELETE = """
+    mutation checkoutLineDelete($checkoutId: ID!, $lineId: ID!) {
+        checkoutLineDelete(checkoutId: $checkoutId, lineId: $lineId) {
+            checkout {
+                token
+                lines {
+                    quantity
+                    variant {
+                        id
+                    }
+                }
+            }
+            errors {
+                field
+                message
+            }
+        }
+    }
+"""
+
+
 def test_checkout_line_delete(user_api_client, cart_with_item):
     cart = cart_with_item
     assert cart.lines.count() == 1
     line = cart.lines.first()
     assert line.quantity == 3
-    query = """
-        mutation checkoutLineDelete($checkoutId: ID!, $lineId: ID!) {
-            checkoutLineDelete(checkoutId: $checkoutId, lineId: $lineId) {
-                checkout {
-                    token
-                    lines {
-                        quantity
-                        variant {
-                            id
-                        }
-                    }
-                }
-                errors {
-                    field
-                    message
-                }
-            }
-        }
-    """
+
     checkout_id = graphene.Node.to_global_id('Checkout', cart.pk)
     line_id = graphene.Node.to_global_id('CheckoutLine', line.pk)
 
     variables = {'checkoutId': checkout_id, 'lineId': line_id}
-    response = user_api_client.post_graphql(query, variables)
+    response = user_api_client.post_graphql(
+        MUTATION_CHECKOUT_LINES_DELETE, variables)
     content = get_graphql_content(response)
 
     data = content['data']['checkoutLineDelete']
@@ -1162,3 +1167,133 @@ def test_is_fully_paid_no_payment(cart_with_item):
     checkout = cart_with_item
     is_paid = is_fully_paid(checkout, None, None)
     assert not is_paid
+
+
+MUTATION_CHECKOUT_UPDATE_VOUCHER = """
+    mutation($checkoutId: ID!, $voucherCode: String) {
+        checkoutUpdateVoucher(
+            checkoutId: $checkoutId, voucherCode: $voucherCode) {
+            errors {
+                field
+                message
+            }
+            checkout {
+                id,
+                voucherCode
+            }
+        }
+    }
+"""
+
+
+def _mutate_checkout_update_voucher(client, variables):
+    response = client.post_graphql(MUTATION_CHECKOUT_UPDATE_VOUCHER, variables)
+    content = get_graphql_content(response)
+    return content['data']['checkoutUpdateVoucher']
+
+
+def test_checkout_add_voucher(api_client, cart_with_item, voucher):
+    checkout_id = graphene.Node.to_global_id('Checkout', cart_with_item.pk)
+    variables = {'checkoutId': checkout_id, 'voucherCode': voucher.code}
+    data = _mutate_checkout_update_voucher(api_client, variables)
+
+    assert not data['errors']
+    assert data['checkout']['id'] == checkout_id
+    assert data['checkout']['voucherCode'] == voucher.code
+
+
+def test_checkout_remove_voucher(api_client, cart_with_item):
+    checkout_id = graphene.Node.to_global_id('Checkout', cart_with_item.pk)
+    variables = {'checkoutId': checkout_id}
+    data = _mutate_checkout_update_voucher(api_client, variables)
+
+    assert not data['errors']
+    assert data['checkout']['id'] == checkout_id
+    assert data['checkout']['voucherCode'] is None
+    assert cart_with_item.voucher_code is None
+
+
+def test_checkout_add_voucher_invalid_checkout(api_client, voucher):
+    variables = {'checkoutId': 'XXX', 'voucherCode': voucher.code}
+    data = _mutate_checkout_update_voucher(api_client, variables)
+
+    assert data['errors']
+    assert data['errors'][0]['field'] == 'checkoutId'
+
+
+def test_checkout_add_voucher_invalid_code(api_client, cart_with_item):
+    checkout_id = graphene.Node.to_global_id('Checkout', cart_with_item.pk)
+    variables = {'checkoutId': checkout_id, 'voucherCode': 'XXX'}
+    data = _mutate_checkout_update_voucher(api_client, variables)
+
+    assert data['errors']
+    assert data['errors'][0]['field'] == 'voucherCode'
+
+
+def test_checkout_add_voucher_not_applicable_voucher(
+        api_client, cart_with_item, voucher_with_high_min_amount_spent):
+    checkout_id = graphene.Node.to_global_id('Checkout', cart_with_item.pk)
+    variables = {
+        'checkoutId': checkout_id,
+        'voucherCode': voucher_with_high_min_amount_spent.code}
+    data = _mutate_checkout_update_voucher(api_client, variables)
+
+    assert data['errors']
+    assert data['errors'][0]['field'] == 'voucherCode'
+
+
+def test_checkout_lines_delete_with_not_applicable_voucher(
+        user_api_client, cart_with_item, voucher):
+    voucher.min_amount_spent = cart_with_item.get_subtotal().gross
+    voucher.save(update_fields=['min_amount_spent'])
+
+    add_voucher_to_cart(voucher, cart_with_item)
+    assert cart_with_item.voucher_code == voucher.code
+
+    line = cart_with_item.lines.first()
+
+    checkout_id = graphene.Node.to_global_id('Checkout', cart_with_item.pk)
+    line_id = graphene.Node.to_global_id('CheckoutLine', line.pk)
+    variables = {'checkoutId': checkout_id, 'lineId': line_id}
+    response = user_api_client.post_graphql(
+        MUTATION_CHECKOUT_LINES_DELETE, variables)
+    content = get_graphql_content(response)
+
+    data = content['data']['checkoutLineDelete']
+    assert not data['errors']
+    cart_with_item.refresh_from_db()
+    assert cart_with_item.lines.count() == 0
+    assert cart_with_item.voucher_code is None
+
+
+def test_checkout_shipping_address_update_with_not_applicable_voucher(
+        user_api_client, cart_with_item, voucher_shipping_type,
+        graphql_address_data, address_other_country, shipping_method):
+    assert cart_with_item.shipping_address is None
+    assert cart_with_item.voucher_code is None
+
+    cart_with_item.shipping_address = address_other_country
+    cart_with_item.shipping_method = shipping_method
+    cart_with_item.save(update_fields=['shipping_address', 'shipping_method'])
+    assert cart_with_item.shipping_address.country == address_other_country.country
+
+    voucher = voucher_shipping_type
+    assert voucher.countries[0].code == address_other_country.country
+
+    add_voucher_to_cart(voucher, cart_with_item)
+    assert cart_with_item.voucher_code == voucher.code
+
+    checkout_id = graphene.Node.to_global_id('Checkout', cart_with_item.pk)
+    new_address = graphql_address_data
+    variables = {'checkoutId': checkout_id, 'shippingAddress': new_address}
+    response = user_api_client.post_graphql(
+        MUTATION_CHECKOUT_SHIPPING_ADDRESS_UPDATE, variables)
+    content = get_graphql_content(response)
+    data = content['data']['checkoutShippingAddressUpdate']
+    assert not data['errors']
+
+    cart_with_item.refresh_from_db()
+    cart_with_item.shipping_address.refresh_from_db()
+    
+    assert cart_with_item.shipping_address.country == new_address['country']
+    assert cart_with_item.voucher_code is None
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -22,6 +22,7 @@
 from saleor.checkout.utils import add_variant_to_cart
 from saleor.dashboard.menu.utils import update_menu
 from saleor.dashboard.order.utils import fulfill_order_line
+from saleor.discount import VoucherType
 from saleor.discount.models import Sale, Voucher, VoucherTranslation
 from saleor.menu.models import Menu, MenuItem
 from saleor.order import OrderEvents, OrderStatus
@@ -98,6 +99,19 @@ def address(db):  # pylint: disable=W0613
         country='PL',
         phone='+48713988102')
 
+
+@pytest.fixture
+def address_other_country():
+    return Address.objects.create(
+        first_name='John',
+        last_name='Doe',
+        street_address_1='4371 Lucas Knoll Apt. 791',
+        city='Bennettmouth',
+        postal_code='13377',
+        country='IS',
+        phone='')
+
+
 @pytest.fixture
 def graphql_address_data():
     return {
@@ -453,6 +467,23 @@ def voucher(db):  # pylint: disable=W0613
     return Voucher.objects.create(code='mirumee', discount_value=20)
 
 
+@pytest.fixture
+def voucher_with_high_min_amount_spent():
+    return Voucher.objects.create(
+        code='mirumee',
+        discount_value=10,
+        min_amount_spent=Money(1000000, 'USD'))
+
+
+@pytest.fixture
+def voucher_shipping_type():
+    return Voucher.objects.create(
+        code='mirumee',
+        discount_value=10,
+        type=VoucherType.SHIPPING,
+        countries='IS')
+
+
 @pytest.fixture()
 def order_with_lines(
         order, product_type, category, shipping_zone, vatlayer):
diff --git a/tests/test_checkout.py b/tests/test_checkout.py
--- a/tests/test_checkout.py
+++ b/tests/test_checkout.py
@@ -11,7 +11,7 @@
 from saleor.checkout import views
 from saleor.checkout.forms import CartVoucherForm, CountryForm
 from saleor.checkout.utils import (
-    add_variant_to_cart, change_billing_address_in_cart,
+    add_variant_to_cart, add_voucher_to_cart, change_billing_address_in_cart,
     change_shipping_address_in_cart, clear_shipping_method, create_order,
     get_cart_data_for_checkout,
     get_prices_of_products_in_discounted_categories, get_taxes_for_cart,
@@ -975,3 +975,19 @@ def test_get_prices_of_products_in_discounted_categories(cart_with_item):
         lines, [discounted_category])
     # None of the lines are belongs to the discounted category
     assert not discounted_lines
+
+
+def test_add_voucher_to_cart(cart_with_item, voucher):
+    assert cart_with_item.voucher_code is None
+    add_voucher_to_cart(voucher, cart_with_item)
+
+    assert cart_with_item.voucher_code == voucher.code
+
+
+def test_add_voucher_to_cart_fail(
+        cart_with_item, voucher_with_high_min_amount_spent):
+    with pytest.raises(NotApplicable) as e:
+        add_voucher_to_cart(
+            voucher_with_high_min_amount_spent, cart_with_item)
+
+    assert cart_with_item.voucher_code is None