Repository: dj-stripe/dj-stripe

Issue:
BankAccount not syncing
BankAccount doesn't have a stripe_class defined, so `BankAccount.api_retrieve` fails with `'NoneType' object has no attribute 'retrieve'`.

However the fix isn't not quite as simple as setting `BankAccount.stripe_class = stripe.BankAccount` as per other models, because `stripe.BankAccount.retrieve()` is:

```
    @classmethod
    def retrieve(
        cls,
        id,
        api_key=None,
        stripe_version=None,
        stripe_account=None,
        **params
    ):
        raise NotImplementedError(
            "Can't retrieve a bank account without a customer or account ID. "
            "Use customer.sources.retrieve('bank_account_id') or "
            "account.external_accounts.retrieve('bank_account_id') instead."
        )
```

So needs some special casing.

Relates to #828 - this is causing `Payout.sync_from_stripe_data()` to fail for me when trying to sync `Payout.destination`.


Discussion:
See #946 for example data - this is breaking sync for a customer with a `bank_account` in `sources`.

Resolving patch:
diff --git a/djstripe/migrations/0008_auto_20190817_0053.py b/djstripe/migrations/0008_auto_20190817_0053.py
--- a/djstripe/migrations/0008_auto_20190817_0053.py
+++ b/djstripe/migrations/0008_auto_20190817_0053.py
@@ -1,6 +1,7 @@
 # Generated by Django 2.2.4 on 2019-08-17 00:53
 
 from django.db import migrations, models
+
 import djstripe.enums
 import djstripe.fields
 
diff --git a/djstripe/migrations/0009_auto_20190822_0948.py b/djstripe/migrations/0009_auto_20190822_0948.py
new file mode 100644
--- /dev/null
+++ b/djstripe/migrations/0009_auto_20190822_0948.py
@@ -0,0 +1,24 @@
+# Generated by Django 2.2.4 on 2019-08-22 09:48
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("djstripe", "0008_auto_20190817_0053")]
+
+    operations = [
+        migrations.AlterField(
+            model_name="bankaccount",
+            name="account",
+            field=models.ForeignKey(
+                blank=True,
+                help_text="The account the charge was made on behalf of. Null here indicates that this value was never set.",
+                null=True,
+                on_delete=django.db.models.deletion.PROTECT,
+                related_name="bank_account",
+                to="djstripe.Account",
+            ),
+        )
+    ]
diff --git a/djstripe/models/payment_methods.py b/djstripe/models/payment_methods.py
--- a/djstripe/models/payment_methods.py
+++ b/djstripe/models/payment_methods.py
@@ -72,10 +72,103 @@ def resolve(self):
         return self.object_model.objects.get(id=self.id)
 
 
-class BankAccount(StripeModel):
+class LegacySourceMixin:
+    """
+    Mixin for functionality shared between the legacy Card & BankAccount sources
+    """
+
+    @classmethod
+    def _get_customer_from_kwargs(cls, **kwargs):
+        if "customer" not in kwargs or not isinstance(kwargs["customer"], Customer):
+            raise StripeObjectManipulationException(
+                "{}s must be manipulated through a Customer. "
+                "Pass a Customer object into this call.".format(cls.__name__)
+            )
+
+        customer = kwargs["customer"]
+        del kwargs["customer"]
+
+        return customer, kwargs
+
+    @classmethod
+    def _api_create(cls, api_key=djstripe_settings.STRIPE_SECRET_KEY, **kwargs):
+        # OVERRIDING the parent version of this function
+        # Cards & Bank Accounts must be manipulated through a customer or account.
+        # TODO: When managed accounts are supported, this method needs to
+        #     check if either a customer or account is supplied to determine
+        #     the correct object to use.
+
+        customer, clean_kwargs = cls._get_customer_from_kwargs(**kwargs)
+
+        return customer.api_retrieve().sources.create(api_key=api_key, **clean_kwargs)
+
+    @classmethod
+    def api_list(cls, api_key=djstripe_settings.STRIPE_SECRET_KEY, **kwargs):
+        # OVERRIDING the parent version of this function
+        # Cards & Bank Accounts must be manipulated through a customer or account.
+        # TODO: When managed accounts are supported, this method needs to
+        #     check if either a customer or account is supplied to determine
+        #     the correct object to use.
+
+        customer, clean_kwargs = cls._get_customer_from_kwargs(**kwargs)
+
+        return (
+            customer.api_retrieve(api_key=api_key)
+            .sources.list(object=cls.stripe_class.OBJECT_NAME, **clean_kwargs)
+            .auto_paging_iter()
+        )
+
+    def get_stripe_dashboard_url(self):
+        return self.customer.get_stripe_dashboard_url()
+
+    def remove(self):
+        """
+        Removes a legacy source from this customer's account.
+        """
+
+        # First, wipe default source on all customers that use this card.
+        Customer.objects.filter(default_source=self.id).update(default_source=None)
+
+        try:
+            self._api_delete()
+        except InvalidRequestError as exc:
+            if "No such source:" in str(exc) or "No such customer:" in str(exc):
+                # The exception was thrown because the stripe customer or card
+                # was already deleted on the stripe side, ignore the exception
+                pass
+            else:
+                # The exception was raised for another reason, re-raise it
+                raise
+
+        self.delete()
+
+    def api_retrieve(self, api_key=None):
+        # OVERRIDING the parent version of this function
+        # Cards & Banks Accounts must be manipulated through a customer or account.
+        # TODO: When managed accounts are supported, this method needs to check if
+        # either a customer or account is supplied to determine the
+        # correct object to use.
+        api_key = api_key or self.default_api_key
+        customer = self.customer.api_retrieve(api_key=api_key)
+
+        # If the customer is deleted, the sources attribute will be absent.
+        # eg. {"id": "cus_XXXXXXXX", "deleted": True}
+        if "sources" not in customer:
+            # We fake a native stripe InvalidRequestError so that it's caught
+            # like an invalid ID error.
+            raise InvalidRequestError("No such source: %s" % (self.id), "id")
+
+        return customer.sources.retrieve(self.id, expand=self.expand_fields)
+
+
+class BankAccount(LegacySourceMixin, StripeModel):
+    stripe_class = stripe.BankAccount
+
     account = models.ForeignKey(
         "Account",
         on_delete=models.PROTECT,
+        null=True,
+        blank=True,
         related_name="bank_account",
         help_text="The account the charge was made on behalf of. Null here indicates "
         "that this value was never set.",
@@ -123,7 +216,7 @@ class BankAccount(StripeModel):
     status = StripeEnumField(enum=enums.BankAccountStatus)
 
 
-class Card(StripeModel):
+class Card(LegacySourceMixin, StripeModel):
     """
     You can store multiple cards on a customer in order to charge the customer later.
 
@@ -227,89 +320,6 @@ class Card(StripeModel):
         help_text="If the card number is tokenized, this is the method that was used.",
     )
 
-    @staticmethod
-    def _get_customer_from_kwargs(**kwargs):
-        if "customer" not in kwargs or not isinstance(kwargs["customer"], Customer):
-            raise StripeObjectManipulationException(
-                "Cards must be manipulated through a Customer. "
-                "Pass a Customer object into this call."
-            )
-
-        customer = kwargs["customer"]
-        del kwargs["customer"]
-
-        return customer, kwargs
-
-    @classmethod
-    def _api_create(cls, api_key=djstripe_settings.STRIPE_SECRET_KEY, **kwargs):
-        # OVERRIDING the parent version of this function
-        # Cards must be manipulated through a customer or account.
-        # TODO: When managed accounts are supported, this method needs to
-        #     check if either a customer or account is supplied to determine
-        #     the correct object to use.
-
-        customer, clean_kwargs = cls._get_customer_from_kwargs(**kwargs)
-
-        return customer.api_retrieve().sources.create(api_key=api_key, **clean_kwargs)
-
-    @classmethod
-    def api_list(cls, api_key=djstripe_settings.STRIPE_SECRET_KEY, **kwargs):
-        # OVERRIDING the parent version of this function
-        # Cards must be manipulated through a customer or account.
-        # TODO: When managed accounts are supported, this method needs to
-        #     check if either a customer or account is supplied to determine
-        #     the correct object to use.
-
-        customer, clean_kwargs = cls._get_customer_from_kwargs(**kwargs)
-
-        return (
-            customer.api_retrieve(api_key=api_key)
-            .sources.list(object="card", **clean_kwargs)
-            .auto_paging_iter()
-        )
-
-    def get_stripe_dashboard_url(self):
-        return self.customer.get_stripe_dashboard_url()
-
-    def remove(self):
-        """
-        Removes a card from this customer's account.
-        """
-
-        # First, wipe default source on all customers that use this card.
-        Customer.objects.filter(default_source=self.id).update(default_source=None)
-
-        try:
-            self._api_delete()
-        except InvalidRequestError as exc:
-            if "No such source:" in str(exc) or "No such customer:" in str(exc):
-                # The exception was thrown because the stripe customer or card
-                # was already deleted on the stripe side, ignore the exception
-                pass
-            else:
-                # The exception was raised for another reason, re-raise it
-                raise
-
-        self.delete()
-
-    def api_retrieve(self, api_key=None):
-        # OVERRIDING the parent version of this function
-        # Cards must be manipulated through a customer or account.
-        # TODO: When managed accounts are supported, this method needs to check if
-        # either a customer or account is supplied to determine the
-        # correct object to use.
-        api_key = api_key or self.default_api_key
-        customer = self.customer.api_retrieve(api_key=api_key)
-
-        # If the customer is deleted, the sources attribute will be absent.
-        # eg. {"id": "cus_XXXXXXXX", "deleted": True}
-        if "sources" not in customer:
-            # We fake a native stripe InvalidRequestError so that it's caught
-            # like an invalid ID error.
-            raise InvalidRequestError("No such source: %s" % (self.id), "id")
-
-        return customer.sources.retrieve(self.id, expand=self.expand_fields)
-
     def str_parts(self):
         return [
             "brand={brand}".format(brand=self.brand),


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -204,6 +204,16 @@ def default_account():
     "type": "charge",
 }
 
+
+class LegacySourceDict(dict):
+    def delete(self):
+        return self
+
+
+class BankAccountDict(LegacySourceDict):
+    pass
+
+
 FAKE_BANK_ACCOUNT = {
     "id": "ba_16hTzo2eZvKYlo2CeSjfb0tS",
     "object": "bank_account",
@@ -232,10 +242,13 @@ def default_account():
     "status": "new",
 }
 
+FAKE_BANK_ACCOUNT_SOURCE = BankAccountDict(
+    load_fixture("bank_account_ba_fakefakefakefakefake0003.json")
+)
 
-class CardDict(dict):
-    def delete(self):
-        return self
+
+class CardDict(LegacySourceDict):
+    pass
 
 
 FAKE_CARD = CardDict(load_fixture("card_card_fakefakefakefakefake0001.json"))
@@ -715,7 +728,30 @@ def list(self, **kwargs):
         return StripeList(data=self.card_fakes)
 
 
+def convert_source_dict(data):
+    if data:
+        source_type = data["object"]
+        if source_type == "card":
+            data = CardDict(data)
+        elif source_type == "bank_account":
+            data = BankAccountDict(data)
+        elif source_type == "source":
+            data = SourceDict(data)
+        else:
+            raise ValueError("Unknown source type: {}".format(source_type))
+
+    return data
+
+
 class CustomerDict(dict):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        self["default_source"] = convert_source_dict(self["default_source"])
+
+        for n, d in enumerate(self["sources"].get("data", [])):
+            self["sources"]["data"][n] = convert_source_dict(d)
+
     def save(self, idempotency_key=None):
         return self
 
@@ -736,11 +772,6 @@ def create_for_user(self, user):
 
 
 FAKE_CUSTOMER = CustomerDict(load_fixture("customer_cus_6lsBvm5rJ0zyHc.json"))
-if FAKE_CUSTOMER["default_source"]:
-    FAKE_CUSTOMER["default_source"] = CardDict(FAKE_CUSTOMER["default_source"])
-
-for n, d in enumerate(FAKE_CUSTOMER["sources"].get("data", [])):
-    FAKE_CUSTOMER["sources"]["data"][n] = CardDict(d)
 
 
 FAKE_CUSTOMER_II = CustomerDict(load_fixture("customer_cus_4UbFSo9tl62jqj.json"))
@@ -750,6 +781,12 @@ def create_for_user(self, user):
 FAKE_CUSTOMER_III = CustomerDict(load_fixture("customer_cus_4QWKsZuuTHcs7X.json"))
 
 
+# Customer with a Bank Account as default_source
+FAKE_CUSTOMER_IV = CustomerDict(
+    load_fixture("customer_cus_example_with_bank_account.json")
+)
+
+
 FAKE_DISCOUNT_CUSTOMER = {
     "object": "discount",
     "coupon": deepcopy(FAKE_COUPON),
diff --git a/tests/apps/example/management/commands/regenerate_test_fixtures.py b/tests/apps/example/management/commands/regenerate_test_fixtures.py
--- a/tests/apps/example/management/commands/regenerate_test_fixtures.py
+++ b/tests/apps/example/management/commands/regenerate_test_fixtures.py
@@ -78,6 +78,14 @@ def handle(self, *args, **options):
                 "subscriptions",
                 "sources",
             ],
+            djstripe.models.BankAccount: [
+                "id",
+                "bank_name",
+                "customer",
+                "last4",
+                "fingerprint",
+                "status",
+            ],
             djstripe.models.Card: [
                 "id",
                 "address_line1_check",
@@ -169,7 +177,9 @@ def handle(self, *args, **options):
                 tests.FAKE_CUSTOMER,
                 tests.FAKE_CUSTOMER_II,
                 tests.FAKE_CUSTOMER_III,
+                tests.FAKE_CUSTOMER_IV,
             ],
+            djstripe.models.BankAccount: [tests.FAKE_BANK_ACCOUNT_SOURCE],
             djstripe.models.Card: [
                 tests.FAKE_CARD,
                 tests.FAKE_CARD_II,
@@ -394,6 +404,10 @@ def update_fixture_obj(  # noqa: C901
             created, obj = self.get_or_create_stripe_account(
                 old_obj=old_obj, readonly_fields=readonly_fields
             )
+        elif issubclass(model_class, djstripe.models.BankAccount):
+            created, obj = self.get_or_create_stripe_bank_account(
+                old_obj=old_obj, readonly_fields=readonly_fields
+            )
         elif issubclass(model_class, djstripe.models.Card):
             created, obj = self.get_or_create_stripe_card(
                 old_obj=old_obj, readonly_fields=readonly_fields
@@ -463,6 +477,38 @@ def get_or_create_stripe_account(self, old_obj, readonly_fields):
 
         return True, obj
 
+    def get_or_create_stripe_bank_account(self, old_obj, readonly_fields):
+        customer = djstripe.models.Customer(id=old_obj["customer"]).api_retrieve()
+        id_ = old_obj["id"]
+
+        try:
+            obj = customer.sources.retrieve(id_)
+            created = False
+
+            self.stdout.write("    found")
+        except InvalidRequestError:
+            self.stdout.write("    creating")
+
+            create_obj = deepcopy(old_obj)
+
+            # create in Stripe
+            for k in readonly_fields:
+                create_obj.pop(k, None)
+
+            # see https://stripe.com/docs/connect/testing#account-numbers
+            # we've stash the account number in the metadata
+            # so we can regenerate the fixture
+            create_obj["account_number"] = old_obj["metadata"][
+                "djstripe_test_fixture_account_number"
+            ]
+            create_obj["object"] = "bank_account"
+
+            obj = customer.sources.create(source=create_obj)
+
+            created = True
+
+        return created, obj
+
     def get_or_create_stripe_card(self, old_obj, readonly_fields):
         customer = djstripe.models.Customer(id=old_obj["customer"]).api_retrieve()
         id_ = old_obj["id"]
diff --git a/tests/fixtures/bank_account_ba_fakefakefakefakefake0003.json b/tests/fixtures/bank_account_ba_fakefakefakefakefake0003.json
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/bank_account_ba_fakefakefakefakefake0003.json
@@ -0,0 +1,18 @@
+{
+    "id": "ba_fakefakefakefakefake0003",
+    "object": "bank_account",
+    "account_holder_name": "Jane Austen",
+    "account_holder_type": "individual",
+    "bank_name": "STRIPE TEST BANK",
+    "country": "US",
+    "currency": "usd",
+    "customer": "cus_example_with_bank_account",
+    "fingerprint": "6HQmlfTrD91HjjlV",
+    "last4": "6789",
+    "metadata": {
+        "djstripe_test_fake_id": "ba_fakefakefakefakefake0003",
+        "djstripe_test_fixture_account_number": "000123456789"
+    },
+    "routing_number": "110000000",
+    "status": "new"
+}
\ No newline at end of file
diff --git a/tests/fixtures/customer_cus_example_with_bank_account.json b/tests/fixtures/customer_cus_example_with_bank_account.json
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/customer_cus_example_with_bank_account.json
@@ -0,0 +1,74 @@
+{
+    "id": "cus_example_with_bank_account",
+    "object": "customer",
+    "account_balance": 0,
+    "address": null,
+    "balance": 0,
+    "created": 1566463428,
+    "currency": null,
+    "default_source": {
+        "id": "ba_fakefakefakefakefake0003",
+        "object": "bank_account",
+        "account_holder_name": "Jane Austen",
+        "account_holder_type": "individual",
+        "bank_name": "STRIPE TEST BANK",
+        "country": "US",
+        "currency": "usd",
+        "customer": "cus_example_with_bank_account",
+        "fingerprint": "6HQmlfTrD91HjjlV",
+        "last4": "6789",
+        "metadata": {
+            "djstripe_test_fake_id": "ba_fakefakefakefakefake0003",
+            "djstripe_test_fixture_account_number": "000123456789"
+        },
+        "routing_number": "110000000",
+        "status": "new"
+    },
+    "delinquent": false,
+    "description": null,
+    "discount": null,
+    "email": "jane.austen@example.com",
+    "invoice_prefix": "431D68C0",
+    "invoice_settings": {
+        "custom_fields": null,
+        "default_payment_method": null,
+        "footer": null
+    },
+    "livemode": false,
+    "metadata": {},
+    "name": "Jane Austen",
+    "phone": null,
+    "preferred_locales": [],
+    "shipping": null,
+    "sources": {
+        "object": "list",
+        "data": [
+            {
+                "id": "ba_fakefakefakefakefake0003",
+                "object": "bank_account",
+                "account_holder_name": "Jane Austen",
+                "account_holder_type": "individual",
+                "bank_name": "STRIPE TEST BANK",
+                "country": "US",
+                "currency": "usd",
+                "customer": "cus_example_with_bank_account",
+                "fingerprint": "6HQmlfTrD91HjjlV",
+                "last4": "6789",
+                "metadata": {
+                    "djstripe_test_fake_id": "ba_fakefakefakefakefake0003",
+                    "djstripe_test_fixture_account_number": "000123456789"
+                },
+                "routing_number": "110000000",
+                "status": "new"
+            }
+        ],
+        "has_more": false,
+        "total_count": 1,
+        "url": "/v1/customers/cus_example_with_bank_account/sources"
+    },
+    "subscriptions": {},
+    "tax_exempt": "none",
+    "tax_ids": {},
+    "tax_info": null,
+    "tax_info_verification": null
+}
\ No newline at end of file
diff --git a/tests/test_bank_account.py b/tests/test_bank_account.py
new file mode 100644
--- /dev/null
+++ b/tests/test_bank_account.py
@@ -0,0 +1,153 @@
+"""
+dj-stripe Bank Account Model Tests.
+"""
+
+from copy import deepcopy
+from unittest.mock import patch
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+
+from djstripe.exceptions import StripeObjectManipulationException
+from djstripe.models import BankAccount
+from . import (
+    FAKE_BANK_ACCOUNT_SOURCE,
+    FAKE_CUSTOMER_IV,
+    AssertStripeFksMixin,
+    default_account,
+)
+
+
+class BankAccountTest(AssertStripeFksMixin, TestCase):
+    def setUp(self):
+        self.account = default_account()
+        self.user = get_user_model().objects.create_user(
+            username="testuser", email="djstripe@example.com"
+        )
+        fake_empty_customer = deepcopy(FAKE_CUSTOMER_IV)
+        fake_empty_customer["default_source"] = None
+        fake_empty_customer["sources"] = []
+
+        self.customer = fake_empty_customer.create_for_user(self.user)
+
+    def test_create_bank_account_finds_customer(self):
+        bank_account = BankAccount.sync_from_stripe_data(
+            deepcopy(FAKE_BANK_ACCOUNT_SOURCE)
+        )
+
+        self.assertEqual(self.customer, bank_account.customer)
+        self.assertEqual(
+            bank_account.get_stripe_dashboard_url(),
+            self.customer.get_stripe_dashboard_url(),
+        )
+
+        self.assert_fks(
+            bank_account,
+            expected_blank_fks={
+                "djstripe.BankAccount.account",
+                "djstripe.Customer.default_source",
+                "djstripe.Customer.coupon",
+            },
+        )
+
+    def test_api_call_no_customer(self):
+        exception_message = (
+            "BankAccounts must be manipulated through a Customer. "
+            "Pass a Customer object into this call."
+        )
+
+        with self.assertRaisesMessage(
+            StripeObjectManipulationException, exception_message
+        ):
+            BankAccount._api_create()
+
+        with self.assertRaisesMessage(
+            StripeObjectManipulationException, exception_message
+        ):
+            BankAccount.api_list()
+
+    def test_api_call_bad_customer(self):
+        exception_message = (
+            "BankAccounts must be manipulated through a Customer. "
+            "Pass a Customer object into this call."
+        )
+
+        with self.assertRaisesMessage(
+            StripeObjectManipulationException, exception_message
+        ):
+            BankAccount._api_create(customer="fish")
+
+        with self.assertRaisesMessage(
+            StripeObjectManipulationException, exception_message
+        ):
+            BankAccount.api_list(customer="fish")
+
+    @patch(
+        "stripe.Customer.retrieve",
+        return_value=deepcopy(FAKE_CUSTOMER_IV),
+        autospec=True,
+    )
+    def test_api_create(self, customer_retrieve_mock):
+        stripe_bank_account = BankAccount._api_create(
+            customer=self.customer, source=FAKE_BANK_ACCOUNT_SOURCE["id"]
+        )
+
+        self.assertEqual(FAKE_BANK_ACCOUNT_SOURCE, stripe_bank_account)
+
+    @patch("tests.BankAccountDict.delete", autospec=True)
+    @patch(
+        "stripe.BankAccount.retrieve",
+        return_value=deepcopy(FAKE_BANK_ACCOUNT_SOURCE),
+        autospec=True,
+    )
+    @patch(
+        "stripe.Customer.retrieve",
+        return_value=deepcopy(FAKE_CUSTOMER_IV),
+        autospec=True,
+    )
+    def test_remove(
+        self,
+        customer_retrieve_mock,
+        bank_account_retrieve_mock,
+        bank_account_delete_mock,
+    ):
+        stripe_bank_account = BankAccount._api_create(
+            customer=self.customer, source=FAKE_BANK_ACCOUNT_SOURCE["id"]
+        )
+        BankAccount.sync_from_stripe_data(stripe_bank_account)
+
+        self.assertEqual(1, self.customer.bank_account.count())
+
+        bank_account = self.customer.bank_account.all()[0]
+        bank_account.remove()
+
+        self.assertEqual(0, self.customer.bank_account.count())
+        self.assertTrue(bank_account_delete_mock.called)
+
+    @patch(
+        "stripe.Customer.retrieve",
+        return_value=deepcopy(FAKE_CUSTOMER_IV),
+        autospec=True,
+    )
+    def test_remove_already_deleted_card(self, customer_retrieve_mock):
+        stripe_bank_account = BankAccount._api_create(
+            customer=self.customer, source=FAKE_BANK_ACCOUNT_SOURCE["id"]
+        )
+        BankAccount.sync_from_stripe_data(stripe_bank_account)
+
+        self.assertEqual(self.customer.bank_account.count(), 1)
+        bank_account_object = self.customer.bank_account.first()
+        BankAccount.objects.filter(id=stripe_bank_account["id"]).delete()
+        self.assertEqual(self.customer.bank_account.count(), 0)
+        bank_account_object.remove()
+        self.assertEqual(self.customer.bank_account.count(), 0)
+
+    @patch(
+        "stripe.Customer.retrieve",
+        return_value=deepcopy(FAKE_CUSTOMER_IV),
+        autospec=True,
+    )
+    def test_api_list(self, customer_retrieve_mock):
+        bank_account_list = BankAccount.api_list(customer=self.customer)
+
+        self.assertEqual([FAKE_BANK_ACCOUNT_SOURCE], bank_account_list)
diff --git a/tests/test_card.py b/tests/test_card.py
--- a/tests/test_card.py
+++ b/tests/test_card.py
@@ -27,9 +27,11 @@ def setUp(self):
         self.user = get_user_model().objects.create_user(
             username="testuser", email="djstripe@example.com"
         )
-        self.customer = FAKE_CUSTOMER.create_for_user(self.user)
-        self.customer.sources.all().delete()
-        self.customer.legacy_cards.all().delete()
+        fake_empty_customer = deepcopy(FAKE_CUSTOMER)
+        fake_empty_customer["default_source"] = None
+        fake_empty_customer["sources"] = []
+
+        self.customer = fake_empty_customer.create_for_user(self.user)
 
     def test_attach_objects_hook_without_customer(self):
         card = Card.sync_from_stripe_data(deepcopy(FAKE_CARD_III))
@@ -43,6 +45,15 @@ def test_create_card_finds_customer(self):
             card.get_stripe_dashboard_url(), self.customer.get_stripe_dashboard_url()
         )
 
+        self.assert_fks(
+            card,
+            expected_blank_fks={
+                "djstripe.BankAccount.account",
+                "djstripe.Customer.coupon",
+                "djstripe.Customer.default_source",
+            },
+        )
+
     def test_str(self):
         fake_card = deepcopy(FAKE_CARD)
         card = Card.sync_from_stripe_data(fake_card)
@@ -59,7 +70,13 @@ def test_str(self):
             str(card),
         )
 
-        self.assert_fks(card, expected_blank_fks={"djstripe.Customer.coupon"})
+        self.assert_fks(
+            card,
+            expected_blank_fks={
+                "djstripe.Customer.coupon",
+                "djstripe.Customer.default_source",
+            },
+        )
 
     @patch("stripe.Token.create", autospec=True)
     def test_card_create_token(self, token_create_mock):
diff --git a/tests/test_customer.py b/tests/test_customer.py
--- a/tests/test_customer.py
+++ b/tests/test_customer.py
@@ -35,6 +35,7 @@
     FAKE_CUSTOMER,
     FAKE_CUSTOMER_II,
     FAKE_CUSTOMER_III,
+    FAKE_CUSTOMER_IV,
     FAKE_DISCOUNT_CUSTOMER,
     FAKE_INVOICE,
     FAKE_INVOICE_III,
@@ -236,6 +237,27 @@ def test_customer_sync_non_local_card(self, card_retrieve_mock):
             customer.default_source.id, fake_customer["default_source"]["id"]
         )
 
+    @patch(
+        "stripe.BankAccount.retrieve",
+        return_value=FAKE_CUSTOMER_IV["default_source"],
+        autospec=True,
+    )
+    def test_customer_sync_bank_account_source(self, bank_account_retrieve_mock):
+        fake_customer = deepcopy(FAKE_CUSTOMER_IV)
+        user = get_user_model().objects.create_user(
+            username="test_user_sync_bank_account_source"
+        )
+        customer = fake_customer.create_for_user(user)
+
+        self.assertEqual(customer.sources.count(), 0)
+        self.assertEqual(customer.legacy_cards.count(), 0)
+        self.assertEqual(customer.bank_account.count(), 1)
+        self.assertEqual(
+            customer.default_source.id, fake_customer["default_source"]["id"]
+        )
+
+        self.assert_fks(customer, expected_blank_fks={"djstripe.Customer.coupon"})
+
     @patch("stripe.Customer.create", autospec=True)
     def test_customer_sync_no_sources(self, customer_mock):
         fake_customer = deepcopy(FAKE_CUSTOMER)