Repository: dj-stripe/dj-stripe

Issue:
Card.api_retrieve() will crash if the customer associated with the card has been deleted
Situation: Calling `on_delete_subscriber_purge_customer` on a customer that has already been deleted by Stripe.

Eventually, we enter `Customer.purge()` which hits `for source in self.sources.all(): source.remove()`.

However, if the customer has already been deleted, calling `remove()` on a source whose customer has already been deleted will crash, because it first calls `retrieve()` on the card object. This is caused by the `StripeCard.api_retrieve` method:

```py
    def api_retrieve(self, api_key=settings.STRIPE_SECRET_KEY):
        # OVERRIDING the parent version of this function [...]
        return self.customer.api_retrieve(api_key=api_key).sources.retrieve(self.stripe_id, expand=self.expand_fields)
```

Now, what happens if `self.customer.api_retrieve(...)` returns a deleted customer? See for yourself:

```py
>>> customer = Customer.retrieve(id="cus_ACOAhvtRVLJTDT", api_key=settings.STRIPE_SECRET_KEY)
>>> customer
<Customer id=cus_ACOAhvtRVLJTDT at 0x7fd435fffae8> JSON: {
  "deleted": true,
  "id": "cus_ACOAhvtRVLJTDT"
}
>>> "sources" in customer
False
>>> customer.sources.retrieve(...)
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/home/vagrant/env/lib/python3.4/site-packages/stripe/resource.py", line 135, in __getattr__
    raise AttributeError(*err.args)
AttributeError: sources
```


Discussion:
This is pretty hard to fix without reworking the djstripe api because api_retrieve() is always expected to either return a real object, or a native stripe error.

We can check for `if "sources" not in customer...` but then what to do with that? raise our own error? return none? neither of those are handled anywhere.

Resolving patch:
diff --git a/djstripe/stripe_objects.py b/djstripe/stripe_objects.py
--- a/djstripe/stripe_objects.py
+++ b/djstripe/stripe_objects.py
@@ -1199,10 +1199,17 @@ class Meta:
     def api_retrieve(self, api_key=settings.STRIPE_SECRET_KEY):
         # 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.
+        # 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 = 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.stripe_id), "id")
 
-        return self.customer.api_retrieve(api_key=api_key).sources.retrieve(self.stripe_id, expand=self.expand_fields)
+        return customer.sources.retrieve(self.stripe_id, expand=self.expand_fields)
 
     @staticmethod
     def _get_customer_from_kwargs(**kwargs):


Test patch:
diff --git a/tests/test_customer.py b/tests/test_customer.py
--- a/tests/test_customer.py
+++ b/tests/test_customer.py
@@ -139,7 +139,7 @@ def test_add_card_set_default_false_with_single_card_still_becomes_default(self,
         self.assertEqual(1, Card.objects.count())
         self.assertEqual(FAKE_CARD["id"], self.customer.default_source.stripe_id)
 
-    @patch("stripe.Customer.retrieve")
+    @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER))
     def test_cannot_charge(self, customer_retrieve_fake):
         self.customer.delete()
         self.assertFalse(self.customer.can_charge())