Repository: saleor/saleor

Issue:
Stripe charge fails when there is no shipping address
### What I'm trying to achieve
Use the stripe gateway for a non-shippable product

### Steps to reproduce the problem
1. start with a new customer, or existing customer without a shipping address
2. Choose a non-shippable product type and add it to the cart
3. Pay via the stripe gateway

the payment will fail because `_create_stripe_charge` in https://github.com/mirumee/saleor/blob/master/saleor/payment/gateways/stripe/__init__.py assumes that `payment.order` will have a shipping address. 

### What I expected to happen
order to be charged




Discussion:

Resolving patch:
diff --git a/saleor/payment/gateways/stripe/__init__.py b/saleor/payment/gateways/stripe/__init__.py
--- a/saleor/payment/gateways/stripe/__init__.py
+++ b/saleor/payment/gateways/stripe/__init__.py
@@ -1,13 +1,16 @@
-from django_countries import countries
+from decimal import Decimal
+
 import stripe
 
+from . import errors
 from ... import TransactionKind
+from ....payment.models import Payment
 from ...utils import create_transaction
-from . import errors
 from .forms import StripePaymentModalForm
 from .utils import (
-    get_amount_for_stripe, get_amount_from_stripe,
-    get_currency_for_stripe, get_currency_from_stripe)
+    get_amount_for_stripe, get_amount_from_stripe, get_currency_for_stripe,
+    get_currency_from_stripe, get_payment_billing_fullname,
+    shipping_address_to_stripe_dict)
 
 
 def get_client_token(**_):
@@ -27,7 +30,7 @@ def authorize(payment, payment_token, **connection_params):
         # Authorize without capture
         response = _create_stripe_charge(
             client=client, payment=payment, amount=amount,
-            payment_token=payment_token, capture=False)
+            payment_token=payment_token, should_capture=False)
     except stripe.error.StripeError as exc:
         response = _get_error_response_from_exc(exc)
         error = exc.user_message
@@ -84,7 +87,7 @@ def charge(payment, payment_token, amount, **connection_params):
         # Charge without pre-authorize
         response = _create_stripe_charge(
             client=client, payment=payment, amount=amount,
-            payment_token=payment_token, capture=True)
+            payment_token=payment_token, should_capture=True)
     except stripe.error.StripeError as exc:
         response = _get_error_response_from_exc(exc)
         error = exc.user_message
@@ -170,8 +173,11 @@ def _get_client(**connection_params):
     return stripe
 
 
-def _create_stripe_charge(client, payment, amount, payment_token, capture):
-    """Create a charge with specific amount, ignoring payment's total."""
+def _get_stripe_charge_payload(
+        payment: Payment, amount: Decimal, payment_token: str,
+        should_capture: bool):
+    shipping_address = payment.order.shipping_address
+
     # Get currency
     currency = get_currency_for_stripe(payment.currency)
 
@@ -179,30 +185,32 @@ def _create_stripe_charge(client, payment, amount, payment_token, capture):
     stripe_amount = get_amount_for_stripe(amount, currency)
 
     # Get billing name from payment
-    name = '%s %s' % (
-        payment.billing_last_name, payment.billing_first_name)
-
-    # Update shipping address to prevent fraud in Stripe
-    shipping = dict(name=name, address=dict(
-        line1=payment.order.shipping_address.street_address_1,
-        line2=payment.order.shipping_address.street_address_2,
-        city=payment.order.shipping_address.city,
-        state=payment.order.shipping_address.country_area,
-        country=dict(countries).get(
-            payment.order.shipping_address.country, ''),
-        postal_code=payment.order.shipping_address.postal_code,
-    ))
-
-    # Create stripe charge
-    stripe_charge = client.Charge.create(
-        capture=capture,
-        amount=stripe_amount,
-        currency=currency,
-        source=payment_token,
-        shipping=shipping,
-        description=name)
+    name = get_payment_billing_fullname(payment)
 
-    return stripe_charge
+    # Construct the charge payload from the data
+    charge_payload = {
+        'capture': should_capture,
+        'amount': stripe_amount,
+        'currency': currency,
+        'source': payment_token,
+        'description': name}
+
+    if shipping_address:
+        # Update shipping address to prevent fraud in Stripe
+        charge_payload['shipping'] = {
+            'name': name,
+            'address': shipping_address_to_stripe_dict(shipping_address)}
+
+    return charge_payload
+
+
+def _create_stripe_charge(
+        client, payment, amount: Decimal, payment_token: str,
+        should_capture: bool):
+    """Create a charge with specific amount, ignoring payment's total."""
+    charge_payload = _get_stripe_charge_payload(
+        payment, amount, payment_token, should_capture)
+    return client.Charge.create(**charge_payload)
 
 
 def _create_transaction(payment, amount, kind, response):
diff --git a/saleor/payment/gateways/stripe/utils.py b/saleor/payment/gateways/stripe/utils.py
--- a/saleor/payment/gateways/stripe/utils.py
+++ b/saleor/payment/gateways/stripe/utils.py
@@ -1,5 +1,10 @@
 from decimal import Decimal
 
+from django_countries import countries
+
+from ....account.models import Address
+from ....payment.models import Payment
+
 # List of zero-decimal currencies
 # Since there is no public API in Stripe backend or helper function
 # in Stripe's Python library, this list is straight out of Stripe's docs
@@ -50,3 +55,19 @@ def get_currency_from_stripe(currency):
     Stripe's currency is using lowercase while Saleor is using uppercase.
     """
     return currency.upper()
+
+
+def get_payment_billing_fullname(payment: Payment):
+    # Get billing name from payment
+    return '%s %s' % (
+        payment.billing_last_name, payment.billing_first_name)
+
+
+def shipping_address_to_stripe_dict(shipping_address: Address):
+    return {
+        'line1': shipping_address.street_address_1,
+        'line2': shipping_address.street_address_2,
+        'city': shipping_address.city,
+        'state': shipping_address.country_area,
+        'postal_code': shipping_address.postal_code,
+        'country': dict(countries).get(shipping_address.country, '')}


Test patch:
diff --git a/tests/gateway/test_stripe.py b/tests/gateway/test_stripe.py
--- a/tests/gateway/test_stripe.py
+++ b/tests/gateway/test_stripe.py
@@ -4,17 +4,21 @@
 
 import pytest
 import stripe
+from django_countries import countries
 
 from saleor.payment import ChargeStatus, TransactionKind
 from saleor.payment.gateways.stripe import (
-    _create_transaction, _get_client, _get_error_response_from_exc, authorize,
-    capture, charge, get_amount_for_stripe, get_amount_from_stripe,
-    get_client_token, get_currency_for_stripe, get_currency_from_stripe,
-    get_form_class, refund, void)
+    _create_transaction, _get_client, _get_error_response_from_exc,
+    _get_stripe_charge_payload, authorize, capture, charge,
+    get_amount_for_stripe, get_amount_from_stripe, get_client_token,
+    get_currency_for_stripe, get_currency_from_stripe, get_form_class, refund,
+    void)
 from saleor.payment.gateways.stripe.errors import (
     ORDER_NOT_AUTHORIZED, ORDER_NOT_CHARGED)
 from saleor.payment.gateways.stripe.forms import (
     StripeCheckoutWidget, StripePaymentModalForm)
+from saleor.payment.gateways.stripe.utils import (
+    get_payment_billing_fullname, shipping_address_to_stripe_dict)
 
 TRANSACTION_AMOUNT = Decimal(42.42)
 TRANSACTION_REFUND_AMOUNT = Decimal(24.24)
@@ -158,6 +162,23 @@ def test_get_currency_from_stripe():
     assert get_currency_from_stripe('uSd') == 'USD'
 
 
+def test_get_payment_billing_fullname(payment_dummy):
+    expected_fullname = '%s %s' % (
+        payment_dummy.billing_last_name, payment_dummy.billing_first_name)
+    assert get_payment_billing_fullname(payment_dummy) == expected_fullname
+
+
+def test_shipping_address_to_stripe_dict(address):
+    expected_address_dict = {
+        'line1': address.street_address_1,
+        'line2': address.street_address_2,
+        'city': address.city,
+        'state': address.country_area,
+        'postal_code': address.postal_code,
+        'country': dict(countries).get(address.country, '')}
+    assert shipping_address_to_stripe_dict(address) == expected_address_dict
+
+
 def test_widget_with_default_options(stripe_payment, gateway_params):
     widget = StripeCheckoutWidget(stripe_payment, gateway_params)
     assert widget.render() == (
@@ -255,6 +276,43 @@ def test_get_error_response_from_exc():
     assert _get_error_response_from_exc(invalid_request_error) == {}
 
 
+def test_get_stripe_charge_payload_with_shipping(stripe_payment):
+    billing_name = get_payment_billing_fullname(stripe_payment)
+    expected_payload = {
+        'capture': True,
+        'amount': get_amount_for_stripe(
+            TRANSACTION_AMOUNT, TRANSACTION_CURRENCY),
+        'currency': get_currency_for_stripe(TRANSACTION_CURRENCY),
+        'source': FAKE_TOKEN,
+        'description': billing_name,
+        'shipping': {
+            'name': billing_name,
+            'address': shipping_address_to_stripe_dict(
+                stripe_payment.order.shipping_address)}}
+
+    charge_payload = _get_stripe_charge_payload(
+        stripe_payment, TRANSACTION_AMOUNT, FAKE_TOKEN, True)
+
+    assert charge_payload == expected_payload
+
+
+def test_get_stripe_charge_payload_without_shipping(stripe_payment):
+    stripe_payment.order.shipping_address = None
+    billing_name = get_payment_billing_fullname(stripe_payment)
+    expected_payload = {
+        'capture': True,
+        'amount': get_amount_for_stripe(
+            TRANSACTION_AMOUNT, TRANSACTION_CURRENCY),
+        'currency': get_currency_for_stripe(TRANSACTION_CURRENCY),
+        'source': FAKE_TOKEN,
+        'description': billing_name}
+
+    charge_payload = _get_stripe_charge_payload(
+        stripe_payment, TRANSACTION_AMOUNT, FAKE_TOKEN, True)
+
+    assert charge_payload == expected_payload
+
+
 def test_create_transaction_with_charge_success_response(
         stripe_payment,
         stripe_charge_success_response):