Repository: saleor/saleor
Pull request: Add ability to store payment source
Adds ability to store credit card information on payment.

Closes #2700 

This PR introduces:
- new private meta field for user to store external data, ex. gateway information
- new interface for extending parameters for client token generation, ex. braintree token for existing customer
- new flag in CheckoutComplete mutation to rerquest saving user payment source in gateway (needs to be enabled in gateway settings too)
- new flow for charging existing customer instead of one-time-token each time, implemented in Braintree gateway
- improvement in testing, most sensitive tests in braintree gateway test suite are now gone and replaced with VCR-based containing real life sandbox communication
- new gateway method for retrieving customer stored payment sources (credit cards)
- fixes bug with billing and shipping addresses causing _call_gateway_ to fail

<!-- If your changes affect the UI, providing "before" and "after" screenshots will
greatly reduce the amount of work needed to review your work. -->

### Pull Request Checklist

<!-- Please keep this section. It will make maintainer's life easier. -->

1. [x] Privileged views and APIs are guarded by proper permission checks.
1. [x] All visible strings are translated with proper context.
1. [x] All data-formatting is locale-aware (dates, numbers, and so on).
1. [x] Database queries are optimized and the number of queries is constant.
1. [x] Database migration files are up to date.
1. [x] The changes are tested.
1. [x] The code is documented (docstrings, project documentation).
1. [x] GraphQL schema and type definitions are up to date.
1. [x] Changes are mentioned in the changelog.


Problem statement:
Ability to store credit card for further usage
Most of the payment providers allow storing tokenized credit card data in a secure way for further transactions.
So after the first transaction, the customer doesn't have to enter credit card details again but just selects stored card from a list.

- [ ] Adapt payment gateways interface to support storing credit cards
- [ ] Implement flag for storing card data in payment method endpoint
- [ ] Add an endpoint that will return list of stored payment methods for the customer

We should be able to store the credit card data in such way we can retrieve them later (e.g. token) but never as plain.

Hints from issue discussion:
Good news everyone, we are going forward!

We discussed how it should work:
- we should not save Card details in our db - gateways nowadays provide extra features like automatic card update after exp. date. So we need to pull saved cards directly from the provider
- User model needs hstore field for keeping external ids (ex. Stripe does not provide a way to get stripe customer id by email). Proposed format: key - gateway name, value - the id from the gateway
- changes in API: Extend Checkout with new field: available payment methods. This will contain saved cards, and in the future also loyalty points an prepaid cards

I'll start implementing this in my project and soon™️ will send PR to Saleor :)

#3899 - Gift cards payments. Related API changes
@salwator Please see the issue #4285 - we'll need to provide queries for fetching saved payment method to display them in the user profile.

Commit message: Remove unused import

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cbe96982b4b..9e0f833120c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@ All notable, unreleased changes to this project will be documented in this file.
 - Fix internal error when users enters an invalid phone number in an address - #4404 by @NyanKiyoshi
 - New translations:
   - Icelandic
+- Add ability to store used payment sources in gateways, first implemented in Braintree - #4195 by @salwator
 
 ## 2.7.0
 
diff --git a/saleor/account/migrations/0028_user_private_meta.py b/saleor/account/migrations/0028_user_private_meta.py
new file mode 100644
index 00000000000..8f447ed3480
--- /dev/null
+++ b/saleor/account/migrations/0028_user_private_meta.py
@@ -0,0 +1,22 @@
+# Generated by Django 2.2.1 on 2019-05-31 10:44
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+import saleor.core.utils.json_serializer
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("account", "0027_customerevent")]
+
+    operations = [
+        migrations.AddField(
+            model_name="user",
+            name="private_meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+            ),
+        )
+    ]
diff --git a/saleor/account/models.py b/saleor/account/models.py
index a7c59af3183..8ca624fefbb 100644
--- a/saleor/account/models.py
+++ b/saleor/account/models.py
@@ -151,6 +151,8 @@ class User(PermissionsMixin, AbstractBaseUser):
     )
     avatar = VersatileImageField(upload_to="user-avatars", blank=True, null=True)
 
+    private_meta = JSONField(blank=True, default=dict, encoder=CustomJsonEncoder)
+
     USERNAME_FIELD = "email"
 
     objects = UserManager()
@@ -187,6 +189,14 @@ def get_ajax_label(self):
             return "%s %s (%s)" % (address.first_name, address.last_name, self.email)
         return self.email
 
+    def get_private_meta(self, label):
+        return self.private_meta.get(label, {})
+
+    def store_private_meta(self, label, key, value):
+        if label not in self.private_meta:
+            self.private_meta[label] = {}
+        self.private_meta[label][str(key)] = value
+
 
 class CustomerNote(models.Model):
     user = models.ForeignKey(
diff --git a/saleor/graphql/checkout/mutations.py b/saleor/graphql/checkout/mutations.py
index 9ce2322b156..cf566655c92 100644
--- a/saleor/graphql/checkout/mutations.py
+++ b/saleor/graphql/checkout/mutations.py
@@ -28,7 +28,7 @@
 from ...discount import models as voucher_model
 from ...payment import PaymentError
 from ...payment.interface import AddressData
-from ...payment.utils import gateway_process_payment
+from ...payment.utils import gateway_process_payment, store_id_for_payment_gateway
 from ...shipping.models import ShippingMethod as ShippingMethodModel
 from ..account.i18n import I18nMixin
 from ..account.types import AddressInput, User
@@ -469,6 +469,7 @@ class CheckoutComplete(BaseMutation):
 
     class Arguments:
         checkout_id = graphene.ID(description="Checkout ID", required=True)
+        store_source = graphene.Boolean(default_value=False)
 
     class Meta:
         description = (
@@ -478,11 +479,12 @@ class Meta:
         )
 
     @classmethod
-    def perform_mutation(cls, _root, info, checkout_id):
+    def perform_mutation(cls, _root, info, checkout_id, store_source):
         checkout = cls.get_node_or_error(
             info, checkout_id, only_type=Checkout, field="checkout_id"
         )
 
+        user = info.context.user
         clean_checkout(checkout, info.context.discounts)
 
         payment = checkout.get_last_active_payment()
@@ -506,20 +508,22 @@ def perform_mutation(cls, _root, info, checkout_id):
         try:
             billing_address = order_data["billing_address"]  # type: models.Address
             shipping_address = order_data["shipping_address"]  # type: models.Address
-            gateway_process_payment(
+            txn = gateway_process_payment(
                 payment=payment,
                 payment_token=payment.token,
                 billing_address=AddressData(**billing_address.as_data()),
                 shipping_address=AddressData(**shipping_address.as_data()),
+                store_source=store_source,
             )
+            if txn.is_success and txn.customer_id and user.is_authenticated:
+                store_id_for_payment_gateway(user, payment.gateway, txn.customer_id)
+
         except PaymentError as e:
             abort_order_data(order_data)
             raise ValidationError(str(e))
 
         # create the order into the database
-        order = create_order(
-            checkout=checkout, order_data=order_data, user=info.context.user
-        )
+        order = create_order(checkout=checkout, order_data=order_data, user=user)
 
         # remove checkout after order is successfully paid
         checkout.delete()
diff --git a/saleor/graphql/payment/resolvers.py b/saleor/graphql/payment/resolvers.py
index 6ad3c466c6a..45d3ae11d2e 100644
--- a/saleor/graphql/payment/resolvers.py
+++ b/saleor/graphql/payment/resolvers.py
@@ -1,7 +1,15 @@
+from itertools import chain
+
 import graphene_django_optimizer as gql_optimizer
 
 from ...payment import models
-from ...payment.utils import gateway_get_client_token
+from ...payment.interface import TokenConfig
+from ...payment.utils import (
+    extract_id_for_payment_gateway,
+    gateway_get_client_token,
+    list_enabled_gateways,
+    retrieve_customer_sources,
+)
 from ..utils import filter_by_query_param
 
 PAYMENT_SEARCH_FIELDS = ["id"]
@@ -13,5 +21,46 @@ def resolve_payments(info, query):
     return gql_optimizer.query(queryset, info)
 
 
-def resolve_payment_client_token(gateway=None):
+def resolve_payment_client_token(gateway=None, user=None):
+    if user is not None:
+        customer_id = extract_id_for_payment_gateway(user, gateway)
+        if customer_id:
+            token_config = TokenConfig(customer_id=customer_id)
+            return gateway_get_client_token(gateway, token_config)
     return gateway_get_client_token(gateway)
+
+
+def resolve_payment_sources(user):
+    stored_customer_accounts = {
+        gateway: extract_id_for_payment_gateway(user, gateway)
+        for gateway in list_enabled_gateways()
+    }
+    return list(
+        chain(
+            *[
+                prepare_graphql_payment_sources_type(
+                    retrieve_customer_sources(gateway, customer_id)
+                )
+                for gateway, customer_id in stored_customer_accounts.items()
+                if customer_id is not None
+            ]
+        )
+    )
+
+
+def prepare_graphql_payment_sources_type(payment_sources):
+    sources = []
+    for src in payment_sources:
+        sources.append(
+            {
+                "gateway": src.gateway,
+                "credit_card_info": {
+                    "last_digits": src.credit_card_info.last4,
+                    "exp_year": src.credit_card_info.exp_year,
+                    "exp_month": src.credit_card_info.exp_month,
+                    "brand": "",
+                    "first_digits": "",
+                },
+            }
+        )
+    return sources
diff --git a/saleor/graphql/payment/schema.py b/saleor/graphql/payment/schema.py
index bc8864dfd29..b1fbd9a513d 100644
--- a/saleor/graphql/payment/schema.py
+++ b/saleor/graphql/payment/schema.py
@@ -1,16 +1,23 @@
 import graphene
-from graphql_jwt.decorators import permission_required
+from graphql_jwt.decorators import login_required, permission_required
 
 from ..core.fields import PrefetchingConnectionField
 from .enums import PaymentGatewayEnum
 from .mutations import PaymentCapture, PaymentRefund, PaymentVoid
-from .resolvers import resolve_payment_client_token, resolve_payments
-from .types import Payment
+from .resolvers import (
+    resolve_payment_client_token,
+    resolve_payment_sources,
+    resolve_payments,
+)
+from .types import Payment, PaymentSource
 
 
 class PaymentQueries(graphene.ObjectType):
     payment = graphene.Field(Payment, id=graphene.Argument(graphene.ID))
     payments = PrefetchingConnectionField(Payment, description="List of payments")
+    payment_stored_sources = graphene.List(
+        PaymentSource, description="List of stored payment sources"
+    )
     payment_client_token = graphene.Field(
         graphene.String, args={"gateway": PaymentGatewayEnum()}
     )
@@ -23,8 +30,13 @@ def resolve_payment(self, info, **data):
     def resolve_payments(self, info, query=None, **_kwargs):
         return resolve_payments(info, query)
 
-    def resolve_payment_client_token(self, _info, gateway=None):
-        return resolve_payment_client_token(gateway)
+    @login_required
+    def resolve_payment_stored_sources(self, info):
+        return resolve_payment_sources(info.context.user)
+
+    def resolve_payment_client_token(self, info, gateway=None):
+        user = info.context.user if info.context.user.is_authenticated else None
+        return resolve_payment_client_token(gateway, user=user)
 
 
 class PaymentMutations(graphene.ObjectType):
diff --git a/saleor/graphql/payment/types.py b/saleor/graphql/payment/types.py
index 28ceaf10c83..2d6e6aeca48 100644
--- a/saleor/graphql/payment/types.py
+++ b/saleor/graphql/payment/types.py
@@ -52,6 +52,13 @@ class CreditCard(graphene.ObjectType):
     )
 
 
+class PaymentSource(graphene.ObjectType):
+    gateway = graphene.String(description="Payment gateway name", required=True)
+    credit_card_info = graphene.Field(
+        CreditCard, description="Stored credit card details if available"
+    )
+
+
 class Payment(CountableDjangoObjectType):
     charge_status = PaymentChargeStatusEnum(
         description="Internal payment status.", required=True
diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index 3ca772d337f..273884bde60 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -1630,7 +1630,7 @@ type Mutations {
   tokenVerify(token: String!): VerifyToken
   checkoutAddPromoCode(checkoutId: ID!, promoCode: String!): CheckoutAddPromoCode
   checkoutBillingAddressUpdate(billingAddress: AddressInput!, checkoutId: ID!): CheckoutBillingAddressUpdate
-  checkoutComplete(checkoutId: ID!): CheckoutComplete
+  checkoutComplete(checkoutId: ID!, storeSource: Boolean = false): CheckoutComplete
   checkoutCreate(input: CheckoutCreateInput!): CheckoutCreate
   checkoutCustomerAttach(checkoutId: ID!, customerId: ID!): CheckoutCustomerAttach
   checkoutCustomerDetach(checkoutId: ID!): CheckoutCustomerDetach
@@ -2088,6 +2088,11 @@ type PaymentRefund {
   payment: Payment
 }
 
+type PaymentSource {
+  gateway: String!
+  creditCardInfo: CreditCard
+}
+
 type PaymentVoid {
   errors: [Error!]
   payment: Payment
@@ -2505,6 +2510,7 @@ type Query {
   reportProductSales(period: ReportingPeriod!, before: String, after: String, first: Int, last: Int): ProductVariantCountableConnection
   payment(id: ID): Payment
   payments(before: String, after: String, first: Int, last: Int): PaymentCountableConnection
+  paymentStoredSources: [PaymentSource]
   paymentClientToken(gateway: GatewaysEnum): String
   page(id: ID, slug: String): Page
   pages(query: String, before: String, after: String, first: Int, last: Int): PageCountableConnection
diff --git a/saleor/payment/__init__.py b/saleor/payment/__init__.py
index b761f50fc1e..f6b4a743a2d 100644
--- a/saleor/payment/__init__.py
+++ b/saleor/payment/__init__.py
@@ -129,6 +129,7 @@ def get_payment_gateway(gateway_name):
         auto_capture=gateway_config["auto_capture"],
         template_path=gateway_config["template_path"],
         connection_params=gateway_config["connection_params"],
+        store_customer=gateway_config["store_card"],
     )
 
     return gateway_module, config
diff --git a/saleor/payment/gateways/braintree/__init__.py b/saleor/payment/gateways/braintree/__init__.py
index cf034f03b46..8de833ecc2a 100644
--- a/saleor/payment/gateways/braintree/__init__.py
+++ b/saleor/payment/gateways/braintree/__init__.py
@@ -6,7 +6,14 @@
 from django.utils.translation import pgettext_lazy
 
 from ... import TransactionKind
-from ...interface import GatewayConfig, GatewayResponse, PaymentData
+from ...interface import (
+    CreditCardInfo,
+    CustomerSource,
+    GatewayConfig,
+    GatewayResponse,
+    PaymentData,
+    TokenConfig,
+)
 from .errors import DEFAULT_ERROR_MESSAGE, BraintreeException
 from .forms import BraintreePaymentForm
 
@@ -32,6 +39,7 @@
 
 
 def get_customer_data(payment_information: PaymentData) -> Dict:
+    """Provides customer info, use only for new customer creation"""
     billing = payment_information.billing
     return {
         "order_id": payment_information.order_id,
@@ -74,16 +82,16 @@ def extract_gateway_response(braintree_result) -> Dict:
             {"code": error.code, "message": error.message}
             for error in braintree_result.errors.deep_errors
         ]
-
     bt_transaction = braintree_result.transaction
+
     if not bt_transaction:
         return {"errors": errors}
-
     return {
         "transaction_id": getattr(bt_transaction, "id", ""),
         "currency": bt_transaction.currency_iso_code,
         "amount": bt_transaction.amount,  # Decimal type
         "credit_card": bt_transaction.credit_card,
+        "customer_id": bt_transaction.customer_details.id,
         "errors": errors,
     }
 
@@ -109,29 +117,30 @@ def get_braintree_gateway(sandbox_mode, merchant_id, public_key, private_key):
     return gateway
 
 
-def get_client_token(config: GatewayConfig) -> str:
+def get_client_token(config: GatewayConfig, token_config: TokenConfig = None) -> str:
     gateway = get_braintree_gateway(**config.connection_params)
-    client_token = gateway.client_token.generate()
-    return client_token
+    if not token_config:
+        return gateway.client_token.generate()
+    parameters = create_token_params(config, token_config)
+    return gateway.client_token.generate(parameters)
+
+
+def create_token_params(config: GatewayConfig, token_config: TokenConfig) -> dict:
+    params = {}
+    customer_id = token_config.customer_id
+    if customer_id and config.store_customer:
+        params["customer_id"] = customer_id
+    return params
 
 
 def authorize(
     payment_information: PaymentData, config: GatewayConfig
 ) -> GatewayResponse:
-    gateway = get_braintree_gateway(**config.connection_params)
-
     try:
-        result = gateway.transaction.sale(
-            {
-                "amount": str(payment_information.amount),
-                "payment_method_nonce": payment_information.token,
-                "options": {
-                    "submit_for_settlement": config.auto_capture,
-                    "three_d_secure": {"required": THREE_D_SECURE_REQUIRED},
-                },
-                **get_customer_data(payment_information),
-            }
-        )
+        if not payment_information.customer_id:
+            result = transaction_for_new_customer(payment_information, config)
+        else:
+            result = transaction_for_existing_customer(payment_information, config)
     except braintree_sdk.exceptions.NotFoundError:
         raise BraintreeException(DEFAULT_ERROR_MESSAGE)
 
@@ -143,6 +152,7 @@ def authorize(
         kind=kind,
         amount=gateway_response.get("amount", payment_information.amount),
         currency=gateway_response.get("currency", payment_information.currency),
+        customer_id=gateway_response.get("customer_id"),
         transaction_id=gateway_response.get(
             "transaction_id", payment_information.token
         ),
@@ -151,6 +161,38 @@ def authorize(
     )
 
 
+def transaction_for_new_customer(
+    payment_information: PaymentData, config: GatewayConfig
+):
+    gateway = get_braintree_gateway(**config.connection_params)
+    return gateway.transaction.sale(
+        {
+            "amount": str(payment_information.amount),
+            "payment_method_nonce": payment_information.token,
+            "options": {
+                "submit_for_settlement": config.auto_capture,
+                "store_in_vault_on_success": payment_information.reuse_source,
+                "three_d_secure": {"required": THREE_D_SECURE_REQUIRED},
+            },
+            **get_customer_data(payment_information),
+        }
+    )
+
+
+def transaction_for_existing_customer(
+    payment_information: PaymentData, config: GatewayConfig
+):
+    gateway = get_braintree_gateway(**config.connection_params)
+    return gateway.transaction.sale(
+        {
+            "amount": str(payment_information.amount),
+            "customer_id": payment_information.customer_id,
+            "options": {"submit_for_settlement": config.auto_capture},
+            **get_customer_data(payment_information),
+        }
+    )
+
+
 def capture(payment_information: PaymentData, config: GatewayConfig) -> GatewayResponse:
     gateway = get_braintree_gateway(**config.connection_params)
 
@@ -234,3 +276,27 @@ def process_payment(
 ) -> GatewayResponse:
     auth_resp = authorize(payment_information, config)
     return auth_resp
+
+
+def list_client_sources(
+    config: GatewayConfig, customer_id: str
+) -> List[CustomerSource]:
+    gateway = get_braintree_gateway(**config.connection_params)
+    customer = gateway.customer.find(customer_id)
+    if not customer:
+        return []
+    return [extract_credit_card_data(card) for card in customer.credit_cards]
+
+
+def extract_credit_card_data(card):
+    credit_card = CreditCardInfo(
+        exp_year=int(card.expiration_year),
+        exp_month=int(card.expiration_month),
+        last4=card.last_4,
+        name_on_card=card.cardholder_name,
+    )
+    return CustomerSource(
+        id=card.unique_number_identifier,
+        gateway="braintree",
+        credit_card_info=credit_card,
+    )
diff --git a/saleor/payment/interface.py b/saleor/payment/interface.py
index 807af6b3f51..1389d3c7d87 100644
--- a/saleor/payment/interface.py
+++ b/saleor/payment/interface.py
@@ -15,6 +15,7 @@ class GatewayResponse:
     currency: str
     transaction_id: str
     error: Optional[str]
+    customer_id: Optional[str] = None
     raw_response: Optional[Dict[str, str]] = None
 
 
@@ -39,14 +40,23 @@ class PaymentData:
     representation of data. It is required to communicate between Saleor and
     given payment gateway."""
 
-    token: str
     amount: Decimal
     currency: str
     billing: Optional[AddressData]
     shipping: Optional[AddressData]
-    order_id: Optional[int]
+    order_id: int
     customer_ip_address: str
     customer_email: str
+    token: Optional[str] = None
+    customer_id: Optional[str] = None
+    reuse_source: bool = False
+
+
+@dataclass
+class TokenConfig:
+    """Dataclass for payment gateway token fetching customization"""
+
+    customer_id: Optional[str] = None
 
 
 @dataclass
@@ -60,3 +70,23 @@ class GatewayConfig:
     # Each gateway has different connection data so we are not able to create
     # a unified structure
     connection_params: Dict[str, Any]
+    store_customer: bool = False
+
+
+@dataclass
+class CreditCardInfo:
+    """Uniform way to represent Credit Card information"""
+
+    last4: str
+    exp_year: int
+    exp_month: int
+    name_on_card: Optional[str] = None
+
+
+@dataclass
+class CustomerSource:
+    """Dataclass for storing information about stored payment sources in gateways"""
+
+    id: str
+    gateway: str
+    credit_card_info: CreditCardInfo = None
diff --git a/saleor/payment/migrations/0012_transaction_customer_id.py b/saleor/payment/migrations/0012_transaction_customer_id.py
new file mode 100644
index 00000000000..7861eead6f9
--- /dev/null
+++ b/saleor/payment/migrations/0012_transaction_customer_id.py
@@ -0,0 +1,16 @@
+# Generated by Django 2.2.2 on 2019-06-28 12:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("payment", "0011_auto_20190516_0901")]
+
+    operations = [
+        migrations.AddField(
+            model_name="transaction",
+            name="customer_id",
+            field=models.CharField(max_length=256, null=True),
+        )
+    ]
diff --git a/saleor/payment/models.py b/saleor/payment/models.py
index 5370ce49998..840ac966603 100644
--- a/saleor/payment/models.py
+++ b/saleor/payment/models.py
@@ -210,6 +210,7 @@ class Transaction(models.Model):
         max_length=256,
         null=True,
     )
+    customer_id = models.CharField(max_length=256, null=True)
     gateway_response = JSONField(encoder=DjangoJSONEncoder)
 
     class Meta:
diff --git a/saleor/payment/utils.py b/saleor/payment/utils.py
index 0d247089e68..2a904a8dc9a 100644
--- a/saleor/payment/utils.py
+++ b/saleor/payment/utils.py
@@ -1,8 +1,9 @@
 import json
 import logging
+
 from decimal import Decimal
 from functools import wraps
-from typing import Dict
+from typing import Dict, List
 
 from django.conf import settings
 from django.core.serializers.json import DjangoJSONEncoder
@@ -24,7 +25,8 @@
     TransactionKind,
     get_payment_gateway,
 )
-from .interface import AddressData, GatewayResponse, PaymentData
+
+from .interface import AddressData, GatewayResponse, PaymentData, TokenConfig
 from .models import Payment, Transaction
 
 logger = logging.getLogger(__name__)
@@ -41,6 +43,10 @@
 ALLOWED_GATEWAY_KINDS = {choices[0] for choices in TransactionKind.CHOICES}
 
 
+def list_enabled_gateways() -> List[str]:
+    return list(settings.CHECKOUT_PAYMENT_GATEWAYS.keys())
+
+
 def get_gateway_operation_func(gateway, operation_type):
     """Return gateway method based on the operation type to be performed."""
     if operation_type == OperationType.PROCESS_PAYMENT:
@@ -61,6 +67,8 @@ def create_payment_information(
     amount: Decimal = None,
     billing_address: AddressData = None,
     shipping_address: AddressData = None,
+    customer_id: str = None,
+    store_source: bool = False,
 ) -> PaymentData:
     """Extracts order information along with payment details.
 
@@ -85,7 +93,9 @@ def create_payment_information(
         shipping=shipping,
         order_id=order_id,
         customer_ip_address=payment.customer_ip_address,
+        customer_id=customer_id,
         customer_email=payment.billing_email,
+        reuse_source=store_source,
     )
 
 
@@ -227,17 +237,26 @@ def create_transaction(
         amount=gateway_response.amount,
         currency=gateway_response.currency,
         error=gateway_response.error,
+        customer_id=gateway_response.customer_id,
         gateway_response=gateway_response.raw_response or {},
     )
     return txn
 
 
-def gateway_get_client_token(gateway_name: str):
+def gateway_get_client_token(gateway_name: str, token_config: TokenConfig = None):
     """Gets client token, that will be used as a customer's identificator for
     client-side tokenization of the chosen payment method.
     """
+    if not token_config:
+        token_config = TokenConfig()
+    gateway, gateway_config = get_payment_gateway(gateway_name)
+    return gateway.get_client_token(config=gateway_config, token_config=token_config)
+
+
+def gateway_list_client_sources(gateway_name: str, customer_id: str):
+    """Lists all stored customer sources for customer with ID"""
     gateway, gateway_config = get_payment_gateway(gateway_name)
-    return gateway.get_client_token(config=gateway_config)
+    return gateway.list_client_sources(config=gateway_config, customer_id=customer_id)
 
 
 def clean_capture(payment: Payment, amount: Decimal):
@@ -275,9 +294,11 @@ def call_gateway(operation_type, payment, payment_token, **extra_params):
     gateway, gateway_config = get_payment_gateway(payment.gateway)
     gateway_response = None
     error_msg = None
-
+    store_source = (
+        extra_params.pop("store_source", False) and gateway_config.store_customer
+    )
     payment_information = create_payment_information(
-        payment, payment_token, **extra_params
+        payment, payment_token, store_source=store_source, **extra_params
     )
 
     try:
@@ -495,3 +516,31 @@ def gateway_refund(payment, amount: Decimal = None) -> Transaction:
 
     _gateway_postprocess(transaction, payment)
     return transaction
+
+
+GATEWAYS_LABEL = "gateways"
+
+
+def extract_id_for_payment_gateway(user, gateway):
+    key = prepare_label_name(gateway)
+    gateway_config = user.get_private_meta(label=GATEWAYS_LABEL).get(key, {})
+    return gateway_config.get("customer_id", None)
+
+
+def store_id_for_payment_gateway(user, gateway, customer_id):
+    user.store_private_meta(
+        label=GATEWAYS_LABEL,
+        key=prepare_label_name(gateway),
+        value={"customer_id": customer_id},
+    )
+    user.save(update_fields=["private_meta"])
+
+
+def prepare_label_name(s):
+    return s.strip().upper()
+
+
+def retrieve_customer_sources(gateway_name, customer_id):
+    """ Fetches all customer payment sources stored in gateway"""
+    gateway, config = get_payment_gateway(gateway_name)
+    return gateway.list_client_sources(config, customer_id)
diff --git a/saleor/settings.py b/saleor/settings.py
index 46471e6b8f0..5742cc36d85 100644
--- a/saleor/settings.py
+++ b/saleor/settings.py
@@ -599,6 +599,7 @@ def get_host():
         "module": "saleor.payment.gateways.dummy",
         "config": {
             "auto_capture": True,
+            "store_card": False,
             "connection_params": {},
             "template_path": "order/payment/dummy.html",
         },
@@ -606,7 +607,8 @@ def get_host():
     BRAINTREE: {
         "module": "saleor.payment.gateways.braintree",
         "config": {
-            "auto_capture": True,
+            "auto_capture": get_bool_from_env("BRAINTREE_AUTO_CAPTURE", True),
+            "store_card": get_bool_from_env("BRAINTREE_STORE_CARD", False),
             "template_path": "order/payment/braintree.html",
             "connection_params": {
                 "sandbox_mode": get_bool_from_env("BRAINTREE_SANDBOX_MODE", True),
@@ -619,7 +621,8 @@ def get_host():
     RAZORPAY: {
         "module": "saleor.payment.gateways.razorpay",
         "config": {
-            "auto_capture": None,
+            "store_card": get_bool_from_env("RAZORAY_STORE_CARD", False),
+            "auto_capture": get_bool_from_env("RAZORPAY_AUTO_CAPTURE", None),
             "template_path": "order/payment/razorpay.html",
             "connection_params": {
                 "public_key": os.environ.get("RAZORPAY_PUBLIC_KEY"),
@@ -633,7 +636,8 @@ def get_host():
     STRIPE: {
         "module": "saleor.payment.gateways.stripe",
         "config": {
-            "auto_capture": True,
+            "store_card": get_bool_from_env("STRIPE_STORE_CARD", False),
+            "auto_capture": get_bool_from_env("STRIPE_AUTO_CAPTURE", True),
             "template_path": "order/payment/stripe.html",
             "connection_params": {
                 "public_key": os.environ.get("STRIPE_PUBLIC_KEY"),
diff --git a/tests/api/test_payment.py b/tests/api/test_payment.py
index 078c2a4e5de..4b16eba82e8 100644
--- a/tests/api/test_payment.py
+++ b/tests/api/test_payment.py
@@ -1,8 +1,9 @@
 from decimal import Decimal
-from unittest.mock import patch
 
 import graphene
+import pytest
 from prices import TaxedMoney
+from tests.api.utils import get_graphql_content
 
 from saleor.core.utils import get_country_name_by_code
 from saleor.graphql.payment.enums import (
@@ -10,8 +11,12 @@
     PaymentChargeStatusEnum,
     PaymentGatewayEnum,
 )
+from saleor.payment.interface import CreditCardInfo, CustomerSource, TokenConfig
 from saleor.payment.models import ChargeStatus, Payment, TransactionKind
-from tests.api.utils import get_graphql_content
+from saleor.payment.utils import (
+    extract_id_for_payment_gateway,
+    store_id_for_payment_gateway,
+)
 
 VOID_QUERY = """
     mutation PaymentVoid($paymentId: ID!) {
@@ -459,19 +464,121 @@ def test_query_payments(payment_dummy, permission_manage_orders, staff_api_clien
     assert payment_ids == [payment_id]
 
 
-@patch("saleor.graphql.payment.resolvers.gateway_get_client_token")
-def test_query_payment_client_token(mock_get_client_token, user_api_client):
+@pytest.fixture
+def braintree_customer_id():
+    return "1234"
+
+
+def test_store_payment_gateway_meta(customer_user, braintree_customer_id):
+    gateway_name = PaymentGatewayEnum.BRAINTREE.name
+    META = {"gateways": {gateway_name.upper(): {"customer_id": braintree_customer_id}}}
+    store_id_for_payment_gateway(customer_user, gateway_name, braintree_customer_id)
+    assert customer_user.private_meta == META
+    customer_user.refresh_from_db()
+    assert (
+        extract_id_for_payment_gateway(customer_user, gateway_name)
+        == braintree_customer_id
+    )
+
+
+def test_query_payment_client_token(mocker, user_api_client):
     query = """
     query paymentClientToken($gateway: GatewaysEnum) {
         paymentClientToken(gateway: $gateway)
     }
     """
     example_token = "example-token"
-    mock_get_client_token.return_value = example_token
+    mock_get_client_token = mocker.patch(
+        "saleor.graphql.payment.resolvers.gateway_get_client_token",
+        return_value=example_token,
+    )
     variables = {"gateway": PaymentGatewayEnum.BRAINTREE.name}
     response = user_api_client.post_graphql(query, variables)
 
     content = get_graphql_content(response)
-    assert mock_get_client_token.called_once_with(PaymentGatewayEnum.BRAINTREE.name)
+    mock_get_client_token.assert_called_once_with("braintree")
     token = content["data"]["paymentClientToken"]
     assert token == example_token
+
+
+@pytest.fixture
+def token_config_with_customer(braintree_customer_id):
+    return TokenConfig(customer_id=braintree_customer_id)
+
+
+@pytest.fixture
+def set_braintree_customer_id(customer_user, braintree_customer_id):
+    gateway_name = "braintree"
+    store_id_for_payment_gateway(customer_user, gateway_name, braintree_customer_id)
+    return customer_user
+
+
+def test_use_customer_id_from_meta_on_client_token_generation(
+    mocker,
+    token_config_with_customer,
+    braintree_customer_id,
+    set_braintree_customer_id,
+    user_api_client,
+):
+    query = """
+    query paymentClientToken($gateway: GatewaysEnum) {
+        paymentClientToken(gateway: $gateway)
+    }
+    """
+    TOKEN = "sample_token"
+    mock_get_token = mocker.patch(
+        "saleor.graphql.payment.resolvers.gateway_get_client_token",
+        return_value=TOKEN,
+        autospec=True,
+    )
+    variables = {"gateway": PaymentGatewayEnum.BRAINTREE.name}
+    response = user_api_client.post_graphql(query, variables)
+
+    mock_get_token.assert_called_once_with(
+        "braintree", TokenConfig(customer_id=braintree_customer_id)
+    )
+    content = get_graphql_content(response)
+    assert content["data"]["paymentClientToken"] == TOKEN
+
+
+@pytest.fixture
+def enable_braintree(settings):
+    settings.CHECKOUT_PAYMENT_GATEWAYS = {"braintree": None}
+    return settings
+
+
+def test_list_payment_sources(
+    mocker,
+    enable_braintree,
+    braintree_customer_id,
+    set_braintree_customer_id,
+    user_api_client,
+):
+    query = """
+    {
+        paymentStoredSources {
+            gateway
+            creditCardInfo {
+                lastDigits
+            }
+        }
+    }
+    """
+    card = CreditCardInfo(
+        last4="5678", exp_year=2020, exp_month=12, name_on_card="JohnDoe"
+    )
+    source = CustomerSource(id="test1", gateway="braintree", credit_card_info=card)
+    mock_get_source_list = mocker.patch(
+        "saleor.graphql.payment.resolvers.retrieve_customer_sources",
+        return_value=[source],
+        autospec=True,
+    )
+    response = user_api_client.post_graphql(query)
+
+    mock_get_source_list.assert_called_once_with("braintree", braintree_customer_id)
+    content = get_graphql_content(response)["data"]["paymentStoredSources"]
+    assert content is not None and len(content) == 1
+    assert content[0] == {
+        "gateway": "braintree",
+        "creditCardInfo": {"lastDigits": "5678"},
+    }
diff --git a/tests/gateway/cassettes/test_authorize_and_save_customer_id.yaml b/tests/gateway/cassettes/test_authorize_and_save_customer_id.yaml
new file mode 100644
index 00000000000..bef1d6f3aae
--- /dev/null
+++ b/tests/gateway/cassettes/test_authorize_and_save_customer_id.yaml
@@ -0,0 +1,97 @@
+interactions:
+- request:
+    body: <transaction><amount>100.0</amount><payment_method_nonce>fake-valid-nonce</payment_method_nonce><options><submit_for_settlement
+      type="boolean">true</submit_for_settlement><store_in_vault_on_success type="boolean">true</store_in_vault_on_success><three_d_secure><required
+      type="boolean">false</required></three_d_secure></options><order_id type="integer">8</order_id><billing><first_name>John</first_name><last_name>Doe</last_name><company>Mirumee
+      Software</company><postal_code>53-601</postal_code><street_address>T&#281;czowa
+      7</street_address><extended_address></extended_address><locality>WROC&#321;AW</locality><region></region><country_code_alpha2>PL</country_code_alpha2></billing><risk_data><customer_ip></customer_ip></risk_data><customer><email>test@example.com</email></customer><type>sale</type></transaction>
+    headers:
+      Accept:
+      - application/xml
+      Accept-Encoding:
+      - gzip
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '820'
+      Content-type:
+      - application/xml
+      User-Agent:
+      - Braintree Python 3.49.0
+      X-ApiVersion:
+      - '5'
+    method: POST
+    uri: https://api.sandbox.braintreegateway.com/merchants/9m6qhfxsqzm3cgzw/transactions
+  response:
+    body:
+      string: !!binary |
+        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
+    headers:
+      Cache-Control:
+      - max-age=0, private, must-revalidate
+      Content-Encoding:
+      - gzip
+      Content-Security-Policy:
+      - frame-ancestors 'self'
+      Content-Type:
+      - application/xml; charset=utf-8
+      Date:
+      - Wed, 26 Jun 2019 14:58:29 GMT
+      ETag:
+      - W/"56150bda74a4f07c25cb1c1b05c341cd"
+      Strict-Transport-Security:
+      - max-age=31536000; includeSubDomains
+      Transfer-Encoding:
+      - chunked
+      Vary:
+      - Accept-Encoding
+      X-Authentication:
+      - basic_auth
+      X-BroxyId:
+      - 01-1561561108.960-79.110.196.19-4474532
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - SAMEORIGIN
+      X-Request-Id:
+      - 01-1561561108.960-79.110.196.19-4474532
+      X-Runtime:
+      - '0.603314'
+      X-User:
+      - wgs3tg8h39pyypyc
+      X-XSS-Protection:
+      - 1; mode=block
+    status:
+      code: 201
+      message: Created
+version: 1
diff --git a/tests/gateway/cassettes/test_authorize_incorrect_token.yaml b/tests/gateway/cassettes/test_authorize_incorrect_token.yaml
new file mode 100644
index 00000000000..895c72684ce
--- /dev/null
+++ b/tests/gateway/cassettes/test_authorize_incorrect_token.yaml
@@ -0,0 +1,75 @@
+interactions:
+- request:
+    body: <transaction><amount>80.00</amount><payment_method_nonce>payment-token</payment_method_nonce><options><submit_for_settlement
+      type="boolean">true</submit_for_settlement><store_in_vault_on_success type="boolean">false</store_in_vault_on_success><three_d_secure><required
+      type="boolean">false</required></three_d_secure></options><order_id type="integer">6</order_id><billing><first_name>John</first_name><last_name>Doe</last_name><company>Mirumee
+      Software</company><postal_code>53-601</postal_code><street_address>T&#281;czowa
+      7</street_address><extended_address></extended_address><locality>WROC&#321;AW</locality><region></region><country_code_alpha2>PL</country_code_alpha2></billing><risk_data><customer_ip></customer_ip></risk_data><customer><email>test@example.com</email></customer><type>sale</type></transaction>
+    headers:
+      Accept:
+      - application/xml
+      Accept-Encoding:
+      - gzip
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '818'
+      Content-type:
+      - application/xml
+      User-Agent:
+      - Braintree Python 3.49.0
+      X-ApiVersion:
+      - '5'
+    method: POST
+    uri: https://api.sandbox.braintreegateway.com/merchants/9m6qhfxsqzm3cgzw/transactions
+  response:
+    body:
+      string: !!binary |
+        H4sIAEyyFF0AA6RUTW/bMAy991cYLtCb4qRFuwx11A0bdhg2bNha9FgwNtMI1YdH0W28Xz/ZiWIn
+        c3fZTeQjn8hHUfnNxujkGckrZxfpbDJNE7SFK5V9XKR3t5/EPL2RJzlUSiCRI0HoK2c9ypMkyTuX
+        b497I+GmwkUKRNCk2Q5iAuuh4HDJ1jMeHrGI9nbwhKJQvp1dXl3mWXcegsBMalkz7vh8Y5ZOp7KC
+        xqDlB4O8duWDdbbAPNtHH3AY9B4eUd7ZJ+tebOIowU2lCMtkjGaSZzGjLzs7qHtnRoGyIxmGcF4B
+        gfH/0AuMqy3L+XQynYYWtlYEdwWKbYGiKzA2L9g9oc2z0ZhI4Kr2Mj9oxddLo1iswsw9MmtskyVT
+        HQQcxwa57AiFsuIZas3CWeHroghqyRVo3xK8GtCT8JoQRRluKGo6HBXhr7odTKTb24NJjKfn2VGn
+        uaMSSahSXgUsniO4VFqHVRjwrhR5FhYMys9uHWQdOPooDdH30YX6erMPKZypwDbyq6LaICY/3Ypf
+        gLB93Vukj62cZ9Cie/aXF+JqOgvjHPiGyoeuWUBZUqvm7dnp+Xx2Xfx2L5C8aXU/gAf7tmG0JZYR
+        SqzSi7SddlzibV+uAK24kfc/vn04O704n12/vw/9RXcfSfgYZH6FpmgfLzVd8QJ0tYZz+f1L2/nf
+        /v3gjmaRk/JPogSGIXEdXpZpZ1iNXR1eynHSPmUohgGlJaPnd7gBU2mchJmEde38e67jzLz9e6QH
+        HWbYHV9b+37X/+fTCZ/AyK/8BwAA//8DAHErh2zSBQAA
+    headers:
+      Cache-Control:
+      - no-cache
+      Content-Encoding:
+      - gzip
+      Content-Security-Policy:
+      - frame-ancestors 'self'
+      Content-Type:
+      - application/xml; charset=utf-8
+      Date:
+      - Thu, 27 Jun 2019 12:10:52 GMT
+      Strict-Transport-Security:
+      - max-age=31536000; includeSubDomains
+      Transfer-Encoding:
+      - chunked
+      Vary:
+      - Accept-Encoding
+      X-Authentication:
+      - basic_auth
+      X-BroxyId:
+      - 02-1561637451.810-79.110.196.19-2197475
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - SAMEORIGIN
+      X-Request-Id:
+      - 02-1561637451.810-79.110.196.19-2197475
+      X-Runtime:
+      - '0.211300'
+      X-User:
+      - wgs3tg8h39pyypyc
+      X-XSS-Protection:
+      - 1; mode=block
+    status:
+      code: 422
+      message: Unprocessable Entity
+version: 1
diff --git a/tests/gateway/cassettes/test_authorize_one_time.yaml b/tests/gateway/cassettes/test_authorize_one_time.yaml
new file mode 100644
index 00000000000..3046a629955
--- /dev/null
+++ b/tests/gateway/cassettes/test_authorize_one_time.yaml
@@ -0,0 +1,95 @@
+interactions:
+- request:
+    body: <transaction><amount>80.00</amount><payment_method_nonce>fake-valid-nonce</payment_method_nonce><options><submit_for_settlement
+      type="boolean">false</submit_for_settlement><store_in_vault_on_success type="boolean">false</store_in_vault_on_success><three_d_secure><required
+      type="boolean">false</required></three_d_secure></options><order_id type="integer">1</order_id><billing><first_name>John</first_name><last_name>Doe</last_name><company>Mirumee
+      Software</company><postal_code>53-601</postal_code><street_address>T&#281;czowa
+      7</street_address><extended_address></extended_address><locality>WROC&#321;AW</locality><region></region><country_code_alpha2>PL</country_code_alpha2></billing><risk_data><customer_ip></customer_ip></risk_data><customer><email>test@example.com</email></customer><type>sale</type></transaction>
+    headers:
+      Accept:
+      - application/xml
+      Accept-Encoding:
+      - gzip
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '822'
+      Content-type:
+      - application/xml
+      User-Agent:
+      - Braintree Python 3.49.0
+      X-ApiVersion:
+      - '5'
+    method: POST
+    uri: https://api.sandbox.braintreegateway.com/merchants/9m6qhfxsqzm3cgzw/transactions
+  response:
+    body:
+      string: !!binary |
+        H4sIANSHE10AA6xYbXPbNgz+nl/hc+/6jZHf4ritoq57u61b0y5L19u+9CgJtthIpEpSjt1fP1CU
+        ZMmiHO9u3yzgIUgCIPDA/utdlo62IBUT/GY8vZyMR8AjETO+uRl/vP+ZrMavgwtfS8oVjTSigovR
+        yGdxsNQvvi7gOvc9/DAypakuVEALnQjJvkHse5XIaPU+h0DRFHyv/GlkUSEl7rYnTAmCm0Lw08c7
+        3+uLDZhmouA6WE0uJxPfq76MIgMZJZRrQqPICAmeJ2OyyACUWOtHKnFPF6g8dBESh27EWXoz1rKA
+        sWc3oUqDPAsqZIxItD/1veZ3eV0JVENMqB4ZF9yMY/zULINxMJtMX5DJksyW99PFy6vrl7PFP+iI
+        ZkG5vsjj/7b+sKByt9ICb2A+yhgeHx2FayaVJpxm4FCmdFgXiSynfO/QQEZZGmhQ+jvY0SxP4RLB
+        vmflFvMIoWLaZTdPBHfJ13TX87zXvqEfsjTFND7vtsFbkXDfawmOrhz8KDCNDp+dWwfvbL6N/mwS
+        rtZYnNISALMmjiUoFdw/fzZbTV9F38QjHV2bd9JRV37baeCxCZ8VuwIiIpoyvQ8+3b3/4fmz+Wz6
+        6s0nPGUttigJG3y3Lt8KfKCpfWJXc7KcYMa2ZfUdMdHl3l77g0gpj831WsIuziwlNM0TOgs+/H6A
+        tuVDK+bBh/fOJXPXEo4+lywKltPl0ZpaU2ZFKxHQF+uCx65n22hU9bqolHTfUWIsWoXQZSSnUjN0
+        nwKtU8gAK0R3hcv4oWI+Zb5lNqQ6SpyYhOX52Xn/v73ybgq7ysD56exQnZPDzvMe0vSEupWaZ6Hm
+        T6Gq/OtXqHZ0qoJM1gzSWFW5sFUEpBSSoI9ywRU4r1biWlfvooN32CBPAmoT3ai5rZzElNfYbvsr
+        +0ID3WA/eqR71HwBm+XY4lQ/sH4uRYS7oR/q10FLeGnp+uq31ffY306Cula6R5lODIcY0g6s1JjB
+        wZscNVvDbYYQpWvjmJmToPP7sN5dt4JFJkBrDDyuwNwJQfY9UhjqgbtYTjGA0nRHLDdyqmAHWV7T
+        h1CIFCgfB2uaKsPLGkBNV/AWJKIyrlJciwdwvcGQ8WAxmc5WK1NsebuOLILpajWtGueifipokpQs
+        8C+mKOZK812XipxJG8pMcJ0E0xnShWNhD7sHKpEJzSYdcCmt9q3oATGFpuSyv3JkdZzaWB34wwFw
+        OHAi0tLv7krCMroBUsg0SLTO1UvPowqrtboMJWXcvKAq9Q338XK6N0X8cwaYtvHnVGyEt0VXXOZ8
+        8xr4lknBDeBGYbMNxQ45dmO/KnsScoq88laYTLS/rSYBmuoETwzBR/7AxSPSmpbMgmIImT7o7Wel
+        KiTGENNxU6SGPbZQx5qmJxhajG3vAG3JqvPSvRRpC1ELKvcpVWBVxK7GHw6YjrRbZcWaGC3lEbR3
+        7Strd4m4iErWfzjBQWZBm1SE+P6cHbPm+yZLHeqCs68FVI8SLWDsGJb13vs0rx14JoiKHwZeYaOv
+        KG33FVZTFkkYZqrcd9hE04lLBKChKqbm6eKYgIosP3N0aPCNhZPjnfXRwIRmPaSQln+JJd1QDg84
+        m6h6EDGna9EeJbDQQUBzhqfoy+0dveNLNpLKMbZkIld1UqQiVJFk+SDNaumbEldySJJjWxcxQSZD
+        jAudxbCDxGNJ7cTikY/2MX2DYItwcMSYqTIBnTqwVkRd5wZq1NAohVWlf7auUeRfZirHew1kbaO3
+        rQNnZA59qxjzrel1a4ChLmW2FY/ERrOnRTeEhVSWAsegcYKsx6Wuyh2bFn92b9/F9P6IOBMOO+MA
+        rNfSfQwzS2CmIutzGSyiyEGPMSIDdzc3zwucr0+MFrYV0vgLNjiz3oWtmhJhHBlfYWcX05NtBfps
+        KpDvDYG6nKnllC61atOmQdDTtkqi9ZStho3pBNsvwadochTw6GvhnJoUWUuk4ycmima4Gsre+pkO
+        6TnoRyEfjua8YGKK8GQ5W04XV9ezhe8N4IaYqYkAPclM6xh1E6KkSaCG/1G6JpN5uy0MLW9P0E0b
+        dSfZyQH55FrnED7YtIfG6uEFjSaIf3mbhPw2+Xt+l4ezq/Ut/+Mqur9Lb+dvfO8Au+h0qODiXwAA
+        AP//AwCzQks1ThUAAA==
+    headers:
+      Cache-Control:
+      - max-age=0, private, must-revalidate
+      Content-Encoding:
+      - gzip
+      Content-Security-Policy:
+      - frame-ancestors 'self'
+      Content-Type:
+      - application/xml; charset=utf-8
+      Date:
+      - Wed, 26 Jun 2019 14:57:24 GMT
+      ETag:
+      - W/"699549b866d4f0d64241106d9b3872ad"
+      Strict-Transport-Security:
+      - max-age=31536000; includeSubDomains
+      Transfer-Encoding:
+      - chunked
+      Vary:
+      - Accept-Encoding
+      X-Authentication:
+      - basic_auth
+      X-BroxyId:
+      - 01-1561561043.981-79.110.196.19-4468768
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - SAMEORIGIN
+      X-Request-Id:
+      - 01-1561561043.981-79.110.196.19-4468768
+      X-Runtime:
+      - '0.367291'
+      X-User:
+      - wgs3tg8h39pyypyc
+      X-XSS-Protection:
+      - 1; mode=block
+    status:
+      code: 201
+      message: Created
+version: 1
diff --git a/tests/gateway/cassettes/test_authorize_with_customer_id.yaml b/tests/gateway/cassettes/test_authorize_with_customer_id.yaml
new file mode 100644
index 00000000000..2f4d11f9aee
--- /dev/null
+++ b/tests/gateway/cassettes/test_authorize_with_customer_id.yaml
@@ -0,0 +1,96 @@
+interactions:
+- request:
+    body: <transaction><amount>100.0</amount><customer_id>810066863</customer_id><options><submit_for_settlement
+      type="boolean">true</submit_for_settlement></options><order_id type="integer">1</order_id><billing><first_name>John</first_name><last_name>Doe</last_name><company>Mirumee
+      Software</company><postal_code>53-601</postal_code><street_address>T&#281;czowa
+      7</street_address><extended_address></extended_address><locality>WROC&#321;AW</locality><region></region><country_code_alpha2>PL</country_code_alpha2></billing><risk_data><customer_ip></customer_ip></risk_data><customer><email>test@example.com</email></customer><type>sale</type></transaction>
+    headers:
+      Accept:
+      - application/xml
+      Accept-Encoding:
+      - gzip
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '647'
+      Content-type:
+      - application/xml
+      User-Agent:
+      - Braintree Python 3.49.0
+      X-ApiVersion:
+      - '5'
+    method: POST
+    uri: https://api.sandbox.braintreegateway.com/merchants/9m6qhfxsqzm3cgzw/transactions
+  response:
+    body:
+      string: !!binary |
+        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
+    headers:
+      Cache-Control:
+      - max-age=0, private, must-revalidate
+      Content-Encoding:
+      - gzip
+      Content-Security-Policy:
+      - frame-ancestors 'self'
+      Content-Type:
+      - application/xml; charset=utf-8
+      Date:
+      - Wed, 26 Jun 2019 15:28:13 GMT
+      ETag:
+      - W/"28e2684be1cc92eb4a235606b105596c"
+      Strict-Transport-Security:
+      - max-age=31536000; includeSubDomains
+      Transfer-Encoding:
+      - chunked
+      Vary:
+      - Accept-Encoding
+      X-Authentication:
+      - basic_auth
+      X-BroxyId:
+      - 01-1561562892.756-79.110.196.19-4630334
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - SAMEORIGIN
+      X-Request-Id:
+      - 01-1561562892.756-79.110.196.19-4630334
+      X-Runtime:
+      - '0.707386'
+      X-User:
+      - wgs3tg8h39pyypyc
+      X-XSS-Protection:
+      - 1; mode=block
+    status:
+      code: 201
+      message: Created
+version: 1
diff --git a/tests/gateway/cassettes/test_capture.yaml b/tests/gateway/cassettes/test_capture.yaml
new file mode 100644
index 00000000000..cf16b0b389e
--- /dev/null
+++ b/tests/gateway/cassettes/test_capture.yaml
@@ -0,0 +1,91 @@
+interactions:
+- request:
+    body: <transaction><amount>80.00</amount></transaction>
+    headers:
+      Accept:
+      - application/xml
+      Accept-Encoding:
+      - gzip
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '49'
+      Content-type:
+      - application/xml
+      User-Agent:
+      - Braintree Python 3.49.0
+      X-ApiVersion:
+      - '5'
+    method: PUT
+    uri: https://api.sandbox.braintreegateway.com/merchants/9m6qhfxsqzm3cgzw/transactions/m30bcfym/submit_for_settlement
+  response:
+    body:
+      string: !!binary |
+        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
+    headers:
+      Cache-Control:
+      - max-age=0, private, must-revalidate
+      Content-Encoding:
+      - gzip
+      Content-Security-Policy:
+      - frame-ancestors 'self'
+      Content-Type:
+      - application/xml; charset=utf-8
+      Date:
+      - Thu, 27 Jun 2019 10:16:32 GMT
+      ETag:
+      - W/"7a366120ce51506866b65bb1c9efd773"
+      Strict-Transport-Security:
+      - max-age=31536000; includeSubDomains
+      Transfer-Encoding:
+      - chunked
+      Vary:
+      - Accept-Encoding
+      X-Authentication:
+      - basic_auth
+      X-BroxyId:
+      - 02-1561630591.877-79.110.196.19-1517576
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - SAMEORIGIN
+      X-Request-Id:
+      - 02-1561630591.877-79.110.196.19-1517576
+      X-Runtime:
+      - '0.436030'
+      X-User:
+      - wgs3tg8h39pyypyc
+      X-XSS-Protection:
+      - 1; mode=block
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/gateway/cassettes/test_capture_incorrect_token.yaml b/tests/gateway/cassettes/test_capture_incorrect_token.yaml
new file mode 100644
index 00000000000..6a6c7c4c046
--- /dev/null
+++ b/tests/gateway/cassettes/test_capture_incorrect_token.yaml
@@ -0,0 +1,59 @@
+interactions:
+- request:
+    body: <transaction><amount>80.00</amount></transaction>
+    headers:
+      Accept:
+      - application/xml
+      Accept-Encoding:
+      - gzip
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '49'
+      Content-type:
+      - application/xml
+      User-Agent:
+      - Braintree Python 3.49.0
+      X-ApiVersion:
+      - '5'
+    method: PUT
+    uri: https://api.sandbox.braintreegateway.com/merchants/9m6qhfxsqzm3cgzw/transactions/12345/submit_for_settlement
+  response:
+    body:
+      string: !!binary |
+        H4sIAMmsFF0AAwAAAP//AwAAAAAAAAAAAA==
+    headers:
+      Cache-Control:
+      - no-cache
+      Content-Encoding:
+      - gzip
+      Content-Security-Policy:
+      - frame-ancestors 'self'
+      Content-Type:
+      - application/xml; charset=utf-8
+      Date:
+      - Thu, 27 Jun 2019 11:47:21 GMT
+      Strict-Transport-Security:
+      - max-age=31536000; includeSubDomains
+      Transfer-Encoding:
+      - chunked
+      Vary:
+      - Accept-Encoding
+      X-Authentication:
+      - basic_auth
+      X-BroxyId:
+      - 02-1561636040.719-79.110.196.19-2068338
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - SAMEORIGIN
+      X-Request-Id:
+      - 02-1561636040.719-79.110.196.19-2068338
+      X-Runtime:
+      - '0.079389'
+      X-XSS-Protection:
+      - 1; mode=block
+    status:
+      code: 404
+      message: Not Found
+version: 1
diff --git a/tests/gateway/cassettes/test_list_customer_sources.yaml b/tests/gateway/cassettes/test_list_customer_sources.yaml
new file mode 100644
index 00000000000..0b5e6985729
--- /dev/null
+++ b/tests/gateway/cassettes/test_list_customer_sources.yaml
@@ -0,0 +1,74 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - application/xml
+      Accept-Encoding:
+      - gzip
+      Connection:
+      - keep-alive
+      Content-type:
+      - application/xml
+      User-Agent:
+      - Braintree Python 3.49.0
+      X-ApiVersion:
+      - '5'
+    method: GET
+    uri: https://api.sandbox.braintreegateway.com/merchants/9m6qhfxsqzm3cgzw/customers/595109854
+  response:
+    body:
+      string: !!binary |
+        H4sIALIJGl0AA6RUTW/jOAy991cEvqv+aBI4hePOALsNsMC2h207214GtEXH2ujDleQ06a8f2Y5j
+        p2kPi7mJ7z1SFEUyudkJPtmiNkzJpRdeBt4EZa4ok+ul9/hwS2LvJr1I8tpYJVCnF5NJwmg6W8zC
+        YBHPponvrAZ0ZF6CtMTZCzF/LYudeX0XV/n6/S3xx2yjLpg2lkgQOJGMLz2ra/T8luLwFZMrUYHc
+        n+EogPHUorHfcAei4njppInf4Y2iKpU8j1fA7gx7w8ww+8ndGsEiJWAndl/h0qPOtEygl0ZBuCDB
+        nETzh3B6PYuvo8VL4g8OrX9d0f/nPzh097cfQAqGnJoupTVXGfCmos9XT+90tbAv//5V3D1swr8f
+        vs/u/3gMEn/QHB5BmSU5aGoOaYDWsPca9pTvEIdlTKbTIIziOPGbc483ItLESJ+YAffeoz1WlIpT
+        1J9+Z6dRomkNBjx9lBup3qSLNGCDrJZW74kqCDOmBpnjWH9OHh1/79u6GIfeJ6d9P4bPpFzlYN1I
+        pY//jJRHtNdTzJgdXtKZA1lAzfvEM6U4gvTSpoKNtCUHca3d7xCN65o3DxgF/cj0LrirmG7zIUJJ
+        W6Zh5GbmI/iJeo+gXfmi4ETeoidqpB9zL4AbPHiNMhm6NF/dzrIfTzVdhZyuyu3L7eK/5zAO7ld3
+        4k7++aGhW+cSgdvSNduoJUZYL2MC1khqzdPS2spc+z4Yg9ZcZhqY6x/EtavOG+yb3eFXsBco7U+B
+        tlT0J1dr5W9dn19Wcn2Dcsu0ko1gaUDSTO3cGjzGP97omtGtUZKB3AypnaC9tN150zSM4zDxD0bP
+        uVS04qP56IGjQGMFriB3ynGH88ApWuft2h38B6yXmTozuWZV85Gnm2EYVas26FZBnBfz18TvrJ6r
+        JXutkchaZO1AuMowt6p0SoNsFuVxkM2nMWCMMwinSGkORTSlUTNGX7oeY//e5mxjbFEKRQzdfNGQ
+        R37koV0a3bh+WpF2VYy35QlgWgQo1ei67CzAsBPSi18AAAD//wMAbbQ9a4MHAAA=
+    headers:
+      Cache-Control:
+      - max-age=0, private, must-revalidate
+      Content-Encoding:
+      - gzip
+      Content-Security-Policy:
+      - frame-ancestors 'self'
+      Content-Type:
+      - application/xml; charset=utf-8
+      Date:
+      - Mon, 01 Jul 2019 13:25:06 GMT
+      ETag:
+      - W/"fa7463946d8a584cba7f76b35320371b"
+      Strict-Transport-Security:
+      - max-age=31536000; includeSubDomains
+      Transfer-Encoding:
+      - chunked
+      Vary:
+      - Accept-Encoding
+      X-Authentication:
+      - basic_auth
+      X-BroxyId:
+      - 01-1561987505.981-79.110.196.19-688015
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - SAMEORIGIN
+      X-Request-Id:
+      - 01-1561987505.981-79.110.196.19-688015
+      X-Runtime:
+      - '0.148342'
+      X-User:
+      - wgs3tg8h39pyypyc
+      X-XSS-Protection:
+      - 1; mode=block
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/gateway/cassettes/test_refund.yaml b/tests/gateway/cassettes/test_refund.yaml
new file mode 100644
index 00000000000..29a90eb5ff3
--- /dev/null
+++ b/tests/gateway/cassettes/test_refund.yaml
@@ -0,0 +1,91 @@
+interactions:
+- request:
+    body: <transaction><amount>10.00</amount></transaction>
+    headers:
+      Accept:
+      - application/xml
+      Accept-Encoding:
+      - gzip
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '49'
+      Content-type:
+      - application/xml
+      User-Agent:
+      - Braintree Python 3.49.0
+      X-ApiVersion:
+      - '5'
+    method: POST
+    uri: https://api.sandbox.braintreegateway.com/merchants/9m6qhfxsqzm3cgzw/transactions/rjfqmf3r/refund
+  response:
+    body:
+      string: !!binary |
+        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
+        ne17fcZ79i8AAAD//wMAWT2eIDAVAAA=
+    headers:
+      Cache-Control:
+      - max-age=0, private, must-revalidate
+      Content-Encoding:
+      - gzip
+      Content-Security-Policy:
+      - frame-ancestors 'self'
+      Content-Type:
+      - application/xml; charset=utf-8
+      Date:
+      - Thu, 27 Jun 2019 09:29:32 GMT
+      ETag:
+      - W/"b388a46c06219ff1b5c06ca13a829475"
+      Strict-Transport-Security:
+      - max-age=31536000; includeSubDomains
+      Transfer-Encoding:
+      - chunked
+      Vary:
+      - Accept-Encoding
+      X-Authentication:
+      - basic_auth
+      X-BroxyId:
+      - 01-1561627771.449-79.110.196.19-157306
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - SAMEORIGIN
+      X-Request-Id:
+      - 01-1561627771.449-79.110.196.19-157306
+      X-Runtime:
+      - '0.470029'
+      X-User:
+      - wgs3tg8h39pyypyc
+      X-XSS-Protection:
+      - 1; mode=block
+    status:
+      code: 201
+      message: Created
+version: 1
diff --git a/tests/gateway/cassettes/test_refund_incorrect_token.yaml b/tests/gateway/cassettes/test_refund_incorrect_token.yaml
new file mode 100644
index 00000000000..be7f93ce150
--- /dev/null
+++ b/tests/gateway/cassettes/test_refund_incorrect_token.yaml
@@ -0,0 +1,59 @@
+interactions:
+- request:
+    body: <transaction><amount>10.00</amount></transaction>
+    headers:
+      Accept:
+      - application/xml
+      Accept-Encoding:
+      - gzip
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '49'
+      Content-type:
+      - application/xml
+      User-Agent:
+      - Braintree Python 3.49.0
+      X-ApiVersion:
+      - '5'
+    method: POST
+    uri: https://api.sandbox.braintreegateway.com/merchants/9m6qhfxsqzm3cgzw/transactions/token/refund
+  response:
+    body:
+      string: !!binary |
+        H4sIAFGRFF0AAwAAAP//AwAAAAAAAAAAAA==
+    headers:
+      Cache-Control:
+      - no-cache
+      Content-Encoding:
+      - gzip
+      Content-Security-Policy:
+      - frame-ancestors 'self'
+      Content-Type:
+      - application/xml; charset=utf-8
+      Date:
+      - Thu, 27 Jun 2019 09:50:09 GMT
+      Strict-Transport-Security:
+      - max-age=31536000; includeSubDomains
+      Transfer-Encoding:
+      - chunked
+      Vary:
+      - Accept-Encoding
+      X-Authentication:
+      - basic_auth
+      X-BroxyId:
+      - 01-1561629008.541-79.110.196.19-1330461
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - SAMEORIGIN
+      X-Request-Id:
+      - 01-1561629008.541-79.110.196.19-1330461
+      X-Runtime:
+      - '0.072617'
+      X-XSS-Protection:
+      - 1; mode=block
+    status:
+      code: 404
+      message: Not Found
+version: 1
diff --git a/tests/gateway/cassettes/test_void.yaml b/tests/gateway/cassettes/test_void.yaml
new file mode 100644
index 00000000000..c6ef5b17771
--- /dev/null
+++ b/tests/gateway/cassettes/test_void.yaml
@@ -0,0 +1,91 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - application/xml
+      Accept-Encoding:
+      - gzip
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '0'
+      Content-type:
+      - application/xml
+      User-Agent:
+      - Braintree Python 3.49.0
+      X-ApiVersion:
+      - '5'
+    method: PUT
+    uri: https://api.sandbox.braintreegateway.com/merchants/9m6qhfxsqzm3cgzw/transactions/narvpy2m/void
+  response:
+    body:
+      string: !!binary |
+        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
+    headers:
+      Cache-Control:
+      - max-age=0, private, must-revalidate
+      Content-Encoding:
+      - gzip
+      Content-Security-Policy:
+      - frame-ancestors 'self'
+      Content-Type:
+      - application/xml; charset=utf-8
+      Date:
+      - Thu, 27 Jun 2019 12:03:37 GMT
+      ETag:
+      - W/"f01d9b3327e8588400a4d6abd49138e1"
+      Strict-Transport-Security:
+      - max-age=31536000; includeSubDomains
+      Transfer-Encoding:
+      - chunked
+      Vary:
+      - Accept-Encoding
+      X-Authentication:
+      - basic_auth
+      X-BroxyId:
+      - 02-1561637017.231-79.110.196.19-2159579
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - SAMEORIGIN
+      X-Request-Id:
+      - 02-1561637017.231-79.110.196.19-2159579
+      X-Runtime:
+      - '0.314119'
+      X-User:
+      - wgs3tg8h39pyypyc
+      X-XSS-Protection:
+      - 1; mode=block
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/gateway/cassettes/test_void_incorrect_token.yaml b/tests/gateway/cassettes/test_void_incorrect_token.yaml
new file mode 100644
index 00000000000..97e74526b94
--- /dev/null
+++ b/tests/gateway/cassettes/test_void_incorrect_token.yaml
@@ -0,0 +1,59 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - application/xml
+      Accept-Encoding:
+      - gzip
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '0'
+      Content-type:
+      - application/xml
+      User-Agent:
+      - Braintree Python 3.49.0
+      X-ApiVersion:
+      - '5'
+    method: PUT
+    uri: https://api.sandbox.braintreegateway.com/merchants/9m6qhfxsqzm3cgzw/transactions/incorrect_token/void
+  response:
+    body:
+      string: !!binary |
+        H4sIAD/GFF0AAwAAAP//AwAAAAAAAAAAAA==
+    headers:
+      Cache-Control:
+      - no-cache
+      Content-Encoding:
+      - gzip
+      Content-Security-Policy:
+      - frame-ancestors 'self'
+      Content-Type:
+      - application/xml; charset=utf-8
+      Date:
+      - Thu, 27 Jun 2019 13:35:59 GMT
+      Strict-Transport-Security:
+      - max-age=31536000; includeSubDomains
+      Transfer-Encoding:
+      - chunked
+      Vary:
+      - Accept-Encoding
+      X-Authentication:
+      - basic_auth
+      X-BroxyId:
+      - 02-1561642559.027-79.110.196.19-2739397
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - SAMEORIGIN
+      X-Request-Id:
+      - 02-1561642559.027-79.110.196.19-2739397
+      X-Runtime:
+      - '0.078084'
+      X-XSS-Protection:
+      - 1; mode=block
+    status:
+      code: 404
+      message: Not Found
+version: 1
diff --git a/tests/gateway/test_braintree.py b/tests/gateway/test_braintree.py
index 9363f145397..6055174fc02 100644
--- a/tests/gateway/test_braintree.py
+++ b/tests/gateway/test_braintree.py
@@ -4,13 +4,10 @@
 import pytest
 from braintree import Environment, ErrorResult, SuccessfulResult, Transaction
 from braintree.errors import Errors
-from braintree.exceptions import NotFoundError
 from braintree.validation_error import ValidationError
 from django.core.exceptions import ImproperlyConfigured
 
 from saleor.payment.gateways.braintree import (
-    CONFIRM_MANUALLY,
-    THREE_D_SECURE_REQUIRED,
     TransactionKind,
     authorize,
     capture,
@@ -20,7 +17,7 @@
     get_client_token,
     get_customer_data,
     get_error_for_client,
-    process_payment,
+    list_client_sources,
     refund,
     void,
 )
@@ -29,12 +26,14 @@
     BraintreeException,
 )
 from saleor.payment.gateways.braintree.forms import BraintreePaymentForm
-from saleor.payment.interface import GatewayConfig
+from saleor.payment.interface import (
+    GatewayConfig,
+    TokenConfig,
+    CustomerSource,
+    CreditCardInfo,
+)
 from saleor.payment.utils import create_payment_information
 
-INCORRECT_TOKEN_ERROR = (
-    "Unable to process the transaction. Transaction's token is incorrect " "or expired."
-)
 DEFAULT_ERROR = "Unable to process transaction. Please try again in a moment"
 
 
@@ -46,9 +45,10 @@ def braintree_success_response():
         transaction=Mock(
             id="1x02131",
             spec=Transaction,
-            amount=Decimal("0.20"),
+            amount=Decimal("80.00"),
             created_at="2018-10-20 18:34:22",
             credit_card="",  # FIXME we should provide a proper CreditCard mock
+            customer_details=Mock(id=None),
             additional_processor_response="",
             gateway_rejection_reason="",
             processor_response_code="1000",
@@ -82,16 +82,12 @@ def braintree_error_response(braintree_error):
     )
 
 
-@pytest.fixture
-def braintree_not_found_error():
-    return Mock(side_effect=NotFoundError)
-
-
 @pytest.fixture
 def gateway_config():
     return GatewayConfig(
         template_path="template.html",
         auto_capture=False,
+        store_customer=False,
         connection_params={
             "sandbox_mode": False,
             "merchant_id": "123",
@@ -101,13 +97,6 @@ def gateway_config():
     )
 
 
-def success_gateway_response(gateway_response):
-    data = extract_gateway_response(gateway_response)
-    data.pop("currency")
-    data.pop("amount")
-    return data
-
-
 def test_get_customer_data(payment_dummy):
     payment = payment_dummy
     payment_info = create_payment_information(payment)
@@ -163,6 +152,7 @@ def test_extract_gateway_response(braintree_success_response):
         "credit_card": t.credit_card,
         "errors": [],
         "transaction_id": t.id,
+        "customer_id": None,
     }
     assert result == expected_result
 
@@ -176,7 +166,6 @@ def test_extract_gateway_response_no_transaction(
     }
 
 
-@pytest.mark.integration
 def test_get_braintree_gateway(gateway_config):
     connection_params = gateway_config.connection_params
     result = get_braintree_gateway(**gateway_config.connection_params)
@@ -211,38 +200,65 @@ def test_get_client_token(mock_gateway, gateway_config):
     assert token == expected_token
 
 
-@pytest.mark.integration
-@patch("saleor.payment.gateways.braintree.get_braintree_gateway")
-def test_process_payment_error_response(
-    mock_gateway, payment_dummy, braintree_error_response, gateway_config
-):
-    payment = payment_dummy
-    payment_token = "payment-token"
-    mock_response = Mock(return_value=braintree_error_response)
-    mock_gateway.return_value = Mock(transaction=Mock(sale=mock_response))
+@pytest.fixture
+def gateway_config_with_store_enabled(gateway_config):
+    gateway_config.store_customer = True
+    return gateway_config
 
-    payment_info = create_payment_information(payment, payment_token)
-    response = process_payment(payment_info, gateway_config)
 
-    assert response.kind == TransactionKind.AUTH
-    assert response.is_success is False
+@patch("saleor.payment.gateways.braintree.get_braintree_gateway")
+def test_get_client_token_with_customer_id(
+    mock_gateway, gateway_config_with_store_enabled
+):
+    expected_token = "client-token"
+    mock_generate = Mock(return_value=expected_token)
+    mock_gateway.return_value = Mock(client_token=Mock(generate=mock_generate))
+    token = get_client_token(
+        gateway_config_with_store_enabled, TokenConfig(customer_id="1234")
+    )
+    mock_gateway.assert_called_once_with(
+        **gateway_config_with_store_enabled.connection_params
+    )
+    mock_generate.assert_called_once_with({"customer_id": "1234"})
+    assert token == expected_token
 
 
-@pytest.mark.integration
 @patch("saleor.payment.gateways.braintree.get_braintree_gateway")
-def test_process_payment(
-    mock_gateway, payment_dummy, braintree_success_response, gateway_config
+def test_get_client_token_with_no_customer_id_when_disabled(
+    mock_gateway, gateway_config
 ):
-    gateway_config.auto_capture = True
+    expected_token = "client-token"
+    mock_generate = Mock(return_value=expected_token)
+    mock_gateway.return_value = Mock(client_token=Mock(generate=mock_generate))
+    token = get_client_token(gateway_config, TokenConfig(customer_id="1234"))
+    mock_gateway.assert_called_once_with(**gateway_config.connection_params)
+    mock_generate.assert_called_once_with({})
+    assert token == expected_token
+
+
+def test_braintree_payment_form_incorrect_amount(payment_dummy):
+    amount = Decimal("0.01")
+    data = {"amount": amount, "payment_method_nonce": "fake-nonce"}
+    assert amount != payment_dummy.total
+    payment_info = create_payment_information(payment_dummy)
+
+    form = BraintreePaymentForm(data=data, payment_information=payment_info)
+    assert not form.is_valid()
+    assert form.non_field_errors
+
+
+def test_braintree_payment_form(payment_dummy):
     payment = payment_dummy
-    payment_token = "payment-token"
-    mock_response = Mock(return_value=braintree_success_response)
-    mock_gateway.return_value = Mock(transaction=Mock(sale=mock_response))
 
-    payment_info = create_payment_information(payment, payment_token)
-    response = process_payment(payment_info, gateway_config)
+    data = {"amount": payment.total, "payment_method_nonce": "fake-nonce"}
+    payment_info = create_payment_information(payment)
 
-    assert response.kind == TransactionKind.CAPTURE
+    form = create_form(
+        data=data, payment_information=payment_info, connection_params={"secret": "123"}
+    )
+
+    assert isinstance(form, BraintreePaymentForm)
+    assert form.is_valid()
 
 
 @pytest.mark.integration
@@ -263,250 +279,164 @@ def test_authorize_error_response(
     assert response.error == DEFAULT_ERROR
 
 
-@pytest.mark.integration
-@patch("saleor.payment.gateways.braintree.get_braintree_gateway")
-def test_authorize_incorrect_token(
-    mock_gateway, payment_dummy, braintree_not_found_error, gateway_config
-):
-    payment = payment_dummy
-    payment_token = "payment-token"
-    mock_response = Mock(side_effect=braintree_not_found_error)
-    mock_gateway.return_value = Mock(transaction=Mock(sale=mock_response))
-
-    payment_info = create_payment_information(payment, payment_token)
-    with pytest.raises(BraintreeException) as e:
-        authorize(payment_info, gateway_config)
-    assert str(e.value) == DEFAULT_ERROR_MESSAGE
+@pytest.fixture
+def sandbox_braintree_gateway_config(gateway_config):
+    """Set up your environment variables to record sandbox."""
+    gateway_config.connection_params = {
+        "merchant_id": "9m6qhfxsqzm3cgzw",  # CHANGE WHEN RECORDING, DO NOT COMMIT
+        "public_key": "fake_public_key",  # CHANGE WHEN RECORDING
+        "private_key": "fake_private_key",  # CHANGE WHEN RECORDING
+        "sandbox_mode": True,
+    }
+    gateway_config.auto_capture = True
+    return gateway_config
 
 
 @pytest.mark.integration
-@patch("saleor.payment.gateways.braintree.get_braintree_gateway")
-def test_authorize(
-    mock_gateway, payment_dummy, braintree_success_response, gateway_config
+@pytest.mark.vcr(filter_headers=["authorization"])
+def test_authorize_one_time(
+    payment_dummy, sandbox_braintree_gateway_config, braintree_success_response
 ):
     payment = payment_dummy
-    mock_response = Mock(return_value=braintree_success_response)
-    mock_gateway.return_value = Mock(transaction=Mock(sale=mock_response))
 
-    payment_info = create_payment_information(payment, "auth-token")
-    response = authorize(payment_info, gateway_config)
-    assert not response.error
+    payment_info = create_payment_information(payment, "fake-valid-nonce")
+    sandbox_braintree_gateway_config.auto_capture = False
 
+    response = authorize(payment_info, sandbox_braintree_gateway_config)
+    assert not response.error
     assert response.kind == TransactionKind.AUTH
     assert response.amount == braintree_success_response.transaction.amount
     assert response.currency == braintree_success_response.transaction.currency_iso_code
-    assert response.transaction_id == braintree_success_response.transaction.id
-    assert response.is_success == braintree_success_response.is_success
-
-    mock_response.assert_called_once_with(
-        {
-            "amount": str(payment.total),
-            "payment_method_nonce": "auth-token",
-            "options": {
-                "submit_for_settlement": CONFIRM_MANUALLY,
-                "three_d_secure": {"required": THREE_D_SECURE_REQUIRED},
-            },
-            **get_customer_data(payment_info),
-        }
-    )
+    assert response.is_success is True
 
 
 @pytest.mark.integration
-@patch("saleor.payment.gateways.braintree.get_braintree_gateway")
-def test_refund(
-    mock_gateway, payment_txn_captured, braintree_success_response, gateway_config
+@pytest.mark.vcr(filter_headers=["authorization"])
+def test_authorize_and_save_customer_id(
+    payment_dummy, sandbox_braintree_gateway_config
 ):
-    payment = payment_txn_captured
-    amount = Decimal("10.00")
-    mock_response = Mock(return_value=braintree_success_response)
-    mock_gateway.return_value = Mock(transaction=Mock(refund=mock_response))
+    CUSTOMER_ID = "595109854"  # retrieved from sandbox
+    payment = payment_dummy
 
-    payment_info = create_payment_information(payment, "token", amount)
-    response = refund(payment_info, gateway_config)
+    payment_info = create_payment_information(payment, "fake-valid-nonce")
+    payment_info.amount = 100.00
+    payment_info.reuse_source = True
+
+    sandbox_braintree_gateway_config.store_customer = True
+    response = authorize(payment_info, sandbox_braintree_gateway_config)
     assert not response.error
+    assert response.customer_id == CUSTOMER_ID
 
-    assert response.kind == TransactionKind.REFUND
-    assert response.amount == braintree_success_response.transaction.amount
-    assert response.currency == braintree_success_response.transaction.currency_iso_code
-    assert response.transaction_id == braintree_success_response.transaction.id
-    assert response.is_success == braintree_success_response.is_success
-    mock_response.assert_called_once_with(
-        amount_or_options=str(amount), transaction_id=payment_info.token
-    )
+
+@pytest.mark.integration
+@pytest.mark.vcr(filter_headers=["authorization"])
+def test_authorize_with_customer_id(payment_dummy, sandbox_braintree_gateway_config):
+    CUSTOMER_ID = "810066863"  # retrieved from sandbox
+    payment = payment_dummy
+
+    payment_info = create_payment_information(payment, None)
+    payment_info.amount = 100.00
+    payment_info.customer_id = CUSTOMER_ID
+
+    response = authorize(payment_info, sandbox_braintree_gateway_config)
+    assert not response.error
+    assert response.customer_id == CUSTOMER_ID
+    assert response.is_success
 
 
 @pytest.mark.integration
-@patch("saleor.payment.gateways.braintree.get_braintree_gateway")
-def test_refund_incorrect_token(
-    mock_gateway, payment_txn_captured, braintree_not_found_error, gateway_config
-):
-    payment = payment_txn_captured
+@pytest.mark.vcr(filter_headers=["authorization"])
+def test_refund(payment_txn_captured, sandbox_braintree_gateway_config):
     amount = Decimal("10.00")
+    TRANSACTION_ID = "rjfqmf3r"
+    payment_info = create_payment_information(
+        payment_txn_captured, TRANSACTION_ID, amount
+    )
+    response = refund(payment_info, sandbox_braintree_gateway_config)
+    assert not response.error
 
-    mock_response = Mock(side_effect=braintree_not_found_error)
-    mock_gateway.return_value = Mock(transaction=Mock(refund=mock_response))
-
-    payment_info = create_payment_information(payment, "token", amount)
-    with pytest.raises(BraintreeException) as e:
-        refund(payment_info, gateway_config)
-    assert str(e.value) == DEFAULT_ERROR_MESSAGE
+    assert response.kind == TransactionKind.REFUND
+    assert response.amount == amount
+    assert response.currency == "EUR"
+    assert response.is_success is True
 
 
 @pytest.mark.integration
-@patch("saleor.payment.gateways.braintree.get_braintree_gateway")
-def test_refund_error_response(
-    mock_gateway, payment_txn_captured, braintree_error_response, gateway_config
-):
+@pytest.mark.vcr(filter_headers=["authorization"])
+def test_refund_incorrect_token(payment_txn_captured, sandbox_braintree_gateway_config):
     payment = payment_txn_captured
     amount = Decimal("10.00")
-    mock_response = Mock(return_value=braintree_error_response)
-    mock_gateway.return_value = Mock(transaction=Mock(refund=mock_response))
 
     payment_info = create_payment_information(payment, "token", amount)
-    response = refund(payment_info, gateway_config)
-
-    assert response.raw_response == extract_gateway_response(braintree_error_response)
-    assert not response.is_success
-    assert response.error == DEFAULT_ERROR
+    with pytest.raises(BraintreeException) as e:
+        refund(payment_info, sandbox_braintree_gateway_config)
+    assert str(e.value) == DEFAULT_ERROR_MESSAGE
 
 
 @pytest.mark.integration
-@patch("saleor.payment.gateways.braintree.get_braintree_gateway")
-def test_capture(
-    mock_gateway, payment_txn_preauth, braintree_success_response, gateway_config
-):
+@pytest.mark.vcr(filter_headers=["authorization"])
+def test_capture(payment_txn_preauth, sandbox_braintree_gateway_config):
     payment = payment_txn_preauth
-    amount = Decimal("10.00")
-    mock_response = Mock(return_value=braintree_success_response)
-    mock_gateway.return_value = Mock(
-        transaction=Mock(submit_for_settlement=mock_response)
-    )
+    amount = Decimal("80.00")
 
-    payment_info = create_payment_information(payment, "token", amount)
-    response = capture(payment_info, gateway_config)
+    payment_info = create_payment_information(payment, "m30bcfym", amount)
+    response = capture(payment_info, sandbox_braintree_gateway_config)
     assert not response.error
 
     assert response.kind == TransactionKind.CAPTURE
-    assert response.amount == braintree_success_response.transaction.amount
-    assert response.currency == braintree_success_response.transaction.currency_iso_code
-    assert response.transaction_id == braintree_success_response.transaction.id
-    assert response.is_success == braintree_success_response.is_success
-
-    mock_response.assert_called_once_with(
-        amount=str(amount), transaction_id=payment_info.token
-    )
+    assert response.amount == amount
+    assert response.currency == "EUR"
+    assert response.is_success is True
 
 
 @pytest.mark.integration
-@patch("saleor.payment.gateways.braintree.get_braintree_gateway")
-def test_capture_incorrect_token(
-    mock_gateway, payment_txn_preauth, braintree_not_found_error, gateway_config
-):
-    payment = payment_txn_preauth
-    mock_response = Mock(side_effect=braintree_not_found_error)
-    mock_gateway.return_value = Mock(
-        transaction=Mock(submit_for_settlement=mock_response)
-    )
-
-    payment_info = create_payment_information(payment)
+@pytest.mark.vcr(filter_headers=["authorization"])
+def test_capture_incorrect_token(payment_txn_preauth, sandbox_braintree_gateway_config):
+    payment_info = create_payment_information(payment_txn_preauth, "12345")
     with pytest.raises(BraintreeException) as e:
-        capture(payment_info, gateway_config)
-    assert str(e.value) == DEFAULT_ERROR_MESSAGE
+        response = capture(payment_info, sandbox_braintree_gateway_config)
+        assert str(e.value) == DEFAULT_ERROR_MESSAGE
+        assert response.raw_response == extract_gateway_response(
+            braintree_error_response
+        )
+        assert not response.is_success
+        assert response.error == DEFAULT_ERROR
 
 
 @pytest.mark.integration
-@patch("saleor.payment.gateways.braintree.get_braintree_gateway")
-def test_capture_error_response(
-    mock_gateway, payment_txn_preauth, braintree_error_response, gateway_config
-):
+@pytest.mark.vcr(filter_headers=["authorization"])
+def test_void(payment_txn_preauth, sandbox_braintree_gateway_config):
     payment = payment_txn_preauth
-    mock_response = Mock(return_value=braintree_error_response)
-    mock_gateway.return_value = Mock(
-        transaction=Mock(submit_for_settlement=mock_response)
-    )
-
-    payment_info = create_payment_information(payment, "token")
-    response = capture(payment_info, gateway_config)
-
-    assert response.raw_response == extract_gateway_response(braintree_error_response)
-    assert not response.is_success
-    assert response.error == DEFAULT_ERROR
-
-
-@pytest.mark.integration
-@patch("saleor.payment.gateways.braintree.get_braintree_gateway")
-def test_void(
-    mock_gateway, payment_txn_preauth, braintree_success_response, gateway_config
-):
-    payment = payment_txn_preauth
-    mock_response = Mock(return_value=braintree_success_response)
-    mock_gateway.return_value = Mock(transaction=Mock(void=mock_response))
-
-    payment_info = create_payment_information(payment, "token")
-    response = void(payment_info, gateway_config)
+    payment_info = create_payment_information(payment, "narvpy2m")
+    response = void(payment_info, sandbox_braintree_gateway_config)
     assert not response.error
 
     assert response.kind == TransactionKind.VOID
-    assert response.amount == braintree_success_response.transaction.amount
-    assert response.currency == braintree_success_response.transaction.currency_iso_code
-    assert response.transaction_id == braintree_success_response.transaction.id
-    assert response.is_success == braintree_success_response.is_success
-    mock_response.assert_called_once_with(transaction_id=payment_info.token)
+    assert response.is_success is True
 
 
 @pytest.mark.integration
-@patch("saleor.payment.gateways.braintree.get_braintree_gateway")
-def test_void_incorrect_token(
-    mock_gateway, payment_txn_preauth, braintree_not_found_error, gateway_config
-):
+@pytest.mark.vcr(filter_headers=["authorization"])
+def test_void_incorrect_token(payment_txn_preauth, sandbox_braintree_gateway_config):
     payment = payment_txn_preauth
 
-    mock_response = Mock(side_effect=braintree_not_found_error)
-    mock_gateway.return_value = Mock(transaction=Mock(void=mock_response))
-
-    payment_info = create_payment_information(payment)
+    payment_info = create_payment_information(payment, "incorrect_token")
     with pytest.raises(BraintreeException) as e:
-        void(payment_info, gateway_config)
+        void(payment_info, sandbox_braintree_gateway_config)
     assert str(e.value) == DEFAULT_ERROR_MESSAGE
 
 
 @pytest.mark.integration
-@patch("saleor.payment.gateways.braintree.get_braintree_gateway")
-def test_void_error_response(
-    mock_gateway, payment_txn_preauth, braintree_error_response, gateway_config
-):
-    payment = payment_txn_preauth
-    mock_response = Mock(return_value=braintree_error_response)
-    mock_gateway.return_value = Mock(transaction=Mock(void=mock_response))
-
-    payment_info = create_payment_information(payment)
-    response = void(payment_info, gateway_config)
-
-    assert response.raw_response == extract_gateway_response(braintree_error_response)
-    assert not response.is_success
-    assert response.error == DEFAULT_ERROR
-
-
-def test_braintree_payment_form_incorrect_amount(payment_dummy):
-    amount = Decimal("0.01")
-    data = {"amount": amount, "payment_method_nonce": "fake-nonce"}
-    assert amount != payment_dummy.total
-    payment_info = create_payment_information(payment_dummy)
-
-    form = BraintreePaymentForm(data=data, payment_information=payment_info)
-    assert not form.is_valid()
-    assert form.non_field_errors
-
-
-def test_braintree_payment_form(payment_dummy):
-    payment = payment_dummy
-
-    data = {"amount": payment.total, "payment_method_nonce": "fake-nonce"}
-    payment_info = create_payment_information(payment)
-
-    form = create_form(
-        data=data, payment_information=payment_info, connection_params={"secret": "123"}
+@pytest.mark.vcr(filter_headers=["authorization"])
+def test_list_customer_sources(sandbox_braintree_gateway_config):
+    CUSTOMER_ID = "595109854"  # retrieved from sandbox
+    expected_credit_card = CreditCardInfo(
+        last4="1881", exp_year=2020, exp_month=12, name_on_card=None
     )
-
-    assert isinstance(form, BraintreePaymentForm)
-    assert form.is_valid()
+    expected_customer_source = CustomerSource(
+        id="d0b52c80b648ae8e5a14eddcaf24d254",
+        gateway="braintree",
+        credit_card_info=expected_credit_card,
+    )
+    sources = list_client_sources(sandbox_braintree_gateway_config, CUSTOMER_ID)
+    assert sources == [expected_customer_source]
diff --git a/tests/test_payment.py b/tests/test_payment.py
index 40174533468..262e1fd03d4 100644
--- a/tests/test_payment.py
+++ b/tests/test_payment.py
@@ -13,7 +13,7 @@
     TransactionKind,
     get_payment_gateway,
 )
-from saleor.payment.interface import GatewayConfig, GatewayResponse
+from saleor.payment.interface import GatewayConfig, GatewayResponse, TokenConfig
 from saleor.payment.models import Payment
 from saleor.payment.utils import (
     ALLOWED_GATEWAY_KINDS,
@@ -218,7 +218,9 @@ def test_gateway_get_client_token(get_payment_gateway_mock, gateway_config):
     token = gateway_get_client_token("some-gateway")
 
     assert token == "client-token"
-    get_client_token_mock.assert_called_once_with(config=gateway_config)
+    get_client_token_mock.assert_called_once_with(
+        config=gateway_config, token_config=TokenConfig()
+    )
 
 
 def test_gateway_get_client_token_not_allowed_gateway(settings):


Code review comments:
diff_hunk: @@ -469,6 +469,7 @@ class CheckoutComplete(BaseMutation):
 
     class Arguments:
         checkout_id = graphene.ID(description="Checkout ID", required=True)
+        store_source = graphene.Boolean(default_value=False)
path: saleor/graphql/checkout/mutations.py
text: @user1:
Missing field description, it should say something like "Determines whether to store the payment method for future usage". "Method" or "source" - I'm not sure which one we want to stick to, but it should be used consistently.