Repository: dj-stripe/dj-stripe

Issue:
AttributeError: sources after customer purge, can't fix it
Hi, 
I'm having quite a bit of trouble here: I tried to .purge() a customer as a way of removing all payment sources, and it seems to have broken everything - the customer doesn't want to create again.

```
Environment:


Request Method: POST
Request URL: http://localhost:8000/subscriptions/

Django Version: 2.1.7
Python Version: 3.7.3
Installed Applications:
['django.contrib.admin',
 'django.contrib.auth',
 'django.contrib.contenttypes',
 'django.contrib.sessions',
 'django.contrib.messages',
 'django.contrib.staticfiles',
 'django_extensions',
 'rest_framework',
 'rest_framework.authtoken',
 'django.contrib.sites',
 'corsheaders',
 'accounts',
 'channels',
 'django_celery_beat',
 'tasks',
 'spaces',
 'projects',
 'streams',
 'tags',
 'reactions',
 'comments',
 'presence',
 'invites',
 'django.contrib.humanize',
 'djstripe',
 'billing']
Installed Middleware:
['django.middleware.security.SecurityMiddleware',
 'django.contrib.sessions.middleware.SessionMiddleware',
 'corsheaders.middleware.CorsMiddleware',
 'django.middleware.common.CommonMiddleware',
 'django.middleware.csrf.CsrfViewMiddleware',
 'django.contrib.auth.middleware.AuthenticationMiddleware',
 'django.contrib.messages.middleware.MessageMiddleware',
 'django.middleware.clickjacking.XFrameOptionsMiddleware',
 'hermes.timezone.middleware.TimezoneMiddleware']



Traceback:

File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/stripe/stripe_object.py" in __getattr__
  90.             return self[k]

File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/stripe/stripe_object.py" in __getitem__
  131.                 raise err

File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/stripe/stripe_object.py" in __getitem__
  119.             return super(StripeObject, self).__getitem__(k)

During handling of the above exception ('sources'), another exception occurred:

File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/core/handlers/exception.py" in inner
  34.             response = get_response(request)

File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/core/handlers/base.py" in _get_response
  126.                 response = self.process_exception_by_middleware(e, request)

File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/core/handlers/base.py" in _get_response
  124.                 response = wrapped_callback(request, *callback_args, **callback_kwargs)

File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/views/decorators/csrf.py" in wrapped_view
  54.         return view_func(*args, **kwargs)

File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/rest_framework/viewsets.py" in view
  116.             return self.dispatch(request, *args, **kwargs)

File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/rest_framework/views.py" in dispatch
  495.             response = self.handle_exception(exc)

File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/rest_framework/views.py" in handle_exception
  455.             self.raise_uncaught_exception(exc)

File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/rest_framework/views.py" in dispatch
  492.             response = handler(request, *args, **kwargs)

File "/Users/sergio/Projects/hermes-server/billing/views.py" in create
  114.                 raise e

File "/Users/sergio/Projects/hermes-server/billing/views.py" in create
  91.                     customer.add_card(serializer.data["stripe_token"], set_default=True)

File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/djstripe/models/core.py" in add_card
  768. 		new_stripe_payment_method = stripe_customer.sources.create(source=source)

File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/stripe/stripe_object.py" in __getattr__
  92.             raise AttributeError(*err.args)

Exception Type: AttributeError at /subscriptions/
Exception Value: sources
```


Discussion:
Not only this, when sending failed webhooks, the server returns an integrity error (possibly related):

```
Traceback (most recent call last):
  File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/djstripe/models/base.py", line 442, in _get_or_create_from_stripe_object
    data, current_ids=current_ids, pending_relations=pending_relations, save=save
  File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/djstripe/models/base.py", line 365, in _create_from_stripe_object
    instance.save(force_insert=True)
  File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/db/models/base.py", line 718, in save
    force_update=force_update, update_fields=update_fields)
  File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/db/models/base.py", line 748, in save_base
    updated = self._save_table(raw, cls, force_insert, force_update, using, update_fields)
  File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/db/models/base.py", line 831, in _save_table
    result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
  File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/db/models/base.py", line 869, in _do_insert
    using=using, raw=raw)
  File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/db/models/manager.py", line 82, in manager_method
    return getattr(self.get_queryset(), name)(*args, **kwargs)
  File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/db/models/query.py", line 1136, in _insert
    return query.get_compiler(using=using).execute_sql(return_id)
  File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/db/models/sql/compiler.py", line 1289, in execute_sql
    cursor.execute(sql, params)
  File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/db/backends/utils.py", line 100, in execute
    return super().execute(sql, params)
  File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/db/backends/utils.py", line 68, in execute
    return self._execute_with_wrappers(sql, params, many=False, executor=self._execute)
  File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/db/backends/utils.py", line 77, in _execute_with_wrappers
    return executor(sql, params, many, context)
  File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/db/backends/utils.py", line 85, in _execute
    return self.cursor.execute(sql, params)
  File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/db/utils.py", line 89, in __exit__
    raise dj_exc_value.with_traceback(traceback) from exc_value
  File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/db/backends/utils.py", line 85, in _execute
    return self.cursor.execute(sql, params)
  File "/Users/sergio/.local/share/virtualenvs/hermes-server-EzHChejb/lib/python3.7/site-packages/django/db/backends/sqlite3/base.py", line 298, in execute
    return Database.Cursor.execute(self, query, params)
django.db.utils.IntegrityError: NOT NULL constraint failed: djstripe_customer.account_balance
```
I suspect it's an inconsistency between webhooks that failed and whatnot...

UPDATE: Not a webhook inconsistency. Replicated on clean DB. Customer object is deleted remotely but locally it doesn't seem to be, and even after syncing (or get_or_create), it's still doing this error.
It's actually intentional that `.purge()` doesn't delete the Customer locally, though this was implemented long before my time, I don't know off-hand why.

One thing that definitely looks like a bug is that we're explicitly removing `.legacy_cards` (`Card`s) but not also `.sources` (`Source`s), which looks like it might relate to your initial error.

https://github.com/dj-stripe/dj-stripe/blob/0843d29445ad76a53012d676595bcf317be21a51/djstripe/models/core.py#L752-L785

I'll see if I can reproduce the error locally.
Yeah, I can reproduce the problem.

I think the sources detach I suggested is needed, but the deeper problem is that we're not handling the case of recreating a deleted customer correctly - both the `sources` AttributeError and the not-null account_balance you're seeing are because the code is currently trying to work with a deleted customer object rather than re-creating it.

I ended up implementing a card remove and detach function - exactly what the method does without the delete - but this behavior should be documented, as it’s quite odd.

On Jul 7, 2019, at 12:36 AM, John Carter <notifications@github.com<mailto:notifications@github.com>> wrote:


Yeah, I can reproduce the problem.

I think the sources detach I suggested is needed, but the deeper problem is that we're not handling the case of recreating a deleted customer correctly - both the sources AttributeError and the not-null account_balance you're seeing are because the code is currently trying to work with a deleted customer object rather than re-creating it.

—
You are receiving this because you authored the thread.
Reply to this email directly, view it on GitHub<https://github.com/dj-stripe/dj-stripe/issues/916?email_source=notifications&email_token=AB4XGZJ2PAYOBZDAOVN7SO3P6FXFZA5CNFSM4H6ONNTKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGODZLEOPY#issuecomment-508970815>, or mute the thread<https://github.com/notifications/unsubscribe-auth/AB4XGZNIIK6GMPZIM4MUTA3P6FXFZANCNFSM4H6ONNTA>.


Something that confused me while trying to debug this is that although we set `Customer.subscriber = None` in  `Customer.purge()` (and Stripe doesn't allow un-deletion of Customers), because of the way we handle idempotency keys, if you try to re-create a customer for a given subscriber within 24 hours of its initial creation, it'll fail in a strange way, due to the idempotency key hitting the old customer, so you get the original create response (with the original Customer.id), but if you then do an api_retrieve it's shown as deleted.

I think a possible workaround for this would be to delete the "customer:create:{subscriber_id}" idempotency key  as part of `Customer.purge()`.

Resolving patch:
diff --git a/djstripe/models/core.py b/djstripe/models/core.py
--- a/djstripe/models/core.py
+++ b/djstripe/models/core.py
@@ -18,7 +18,7 @@
 from ..managers import ChargeManager
 from ..signals import WEBHOOK_SIGNALS
 from ..utils import get_friendly_currency_amount
-from .base import StripeModel, logger
+from .base import IdempotencyKey, StripeModel, logger
 from .connect import Account
 
 # Override the default API version used by the Stripe library.
@@ -771,6 +771,13 @@ def purge(self):
 				# The exception was raised for another reason, re-raise it
 				raise
 
+		if self.subscriber:
+			# Delete the idempotency key used by Customer.create()
+			# So re-creating a customer for this subscriber before the key expires
+			# doesn't return the older Customer data
+			idempotency_key_action = "customer:create:{}".format(self.subscriber.pk)
+			IdempotencyKey.objects.filter(action=idempotency_key_action).delete()
+
 		self.subscriber = None
 
 		# Remove sources
@@ -778,6 +785,9 @@ def purge(self):
 		for source in self.legacy_cards.all():
 			source.remove()
 
+		for source in self.sources.all():
+			source.detach()
+
 		self.date_purged = timezone.now()
 		self.save()
 


Test patch:
diff --git a/tests/test_customer.py b/tests/test_customer.py
--- a/tests/test_customer.py
+++ b/tests/test_customer.py
@@ -13,15 +13,17 @@
 from djstripe import settings as djstripe_settings
 from djstripe.exceptions import MultipleSubscriptionException
 from djstripe.models import (
-	Card, Charge, Coupon, Customer, DjstripePaymentMethod, Invoice, Plan, Subscription
+	Card, Charge, Coupon, Customer, DjstripePaymentMethod,
+	IdempotencyKey, Invoice, Plan, Subscription
 )
 from djstripe.settings import STRIPE_SECRET_KEY
 
 from . import (
 	FAKE_ACCOUNT, FAKE_BALANCE_TRANSACTION, FAKE_CARD, FAKE_CARD_V, FAKE_CHARGE,
-	FAKE_COUPON, FAKE_CUSTOMER, FAKE_CUSTOMER_II, FAKE_DISCOUNT_CUSTOMER, FAKE_INVOICE,
-	FAKE_INVOICE_III, FAKE_INVOICEITEM, FAKE_PLAN, FAKE_PRODUCT, FAKE_SUBSCRIPTION,
-	FAKE_SUBSCRIPTION_II, FAKE_UPCOMING_INVOICE, IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED,
+	FAKE_COUPON, FAKE_CUSTOMER, FAKE_CUSTOMER_II, FAKE_CUSTOMER_III,
+	FAKE_DISCOUNT_CUSTOMER, FAKE_INVOICE, FAKE_INVOICE_III, FAKE_INVOICEITEM,
+	FAKE_PLAN, FAKE_PRODUCT, FAKE_SOURCE, FAKE_SUBSCRIPTION, FAKE_SUBSCRIPTION_II,
+	FAKE_UPCOMING_INVOICE, IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED,
 	IS_EXCEPTION_AUTOSPEC_SUPPORTED, IS_STATICMETHOD_AUTOSPEC_SUPPORTED,
 	AssertStripeFksMixin, StripeList, datetime_to_unix, default_account
 )
@@ -237,6 +239,52 @@ def test_customer_purge_leaves_customer_record(self, customer_retrieve_fake):
 		self.assertTrue(not customer.sources.all())
 		self.assertTrue(get_user_model().objects.filter(pk=self.user.pk).exists())
 
+	@patch("stripe.Customer.create", autospec=True)
+	def test_customer_purge_detaches_sources(self, customer_api_create_fake):
+		fake_customer = deepcopy(FAKE_CUSTOMER_III)
+		customer_api_create_fake.return_value = fake_customer
+
+		user = get_user_model().objects.create_user(
+			username="blah", email=FAKE_CUSTOMER_III["email"]
+		)
+
+		Customer.get_or_create(user)
+		customer = Customer.sync_from_stripe_data(deepcopy(FAKE_CUSTOMER_III))
+
+		self.assertIsNotNone(customer.default_source)
+		self.assertNotEqual(customer.sources.count(), 0)
+
+		with patch("stripe.Customer.retrieve", autospec=True), patch(
+			"stripe.Source.retrieve", return_value=deepcopy(FAKE_SOURCE), autospec=True
+		):
+			customer.purge()
+
+		self.assertIsNone(customer.default_source)
+		self.assertEqual(customer.sources.count(), 0)
+
+	@patch(
+		"stripe.Customer.create", return_value=deepcopy(FAKE_CUSTOMER_II), autospec=True
+	)
+	def test_customer_purge_deletes_idempotency_key(self, customer_api_create_fake):
+		# We need to call Customer.get_or_create (which setUp doesn't) to get an idempotency key
+		user = get_user_model().objects.create_user(
+			username="blah", email=FAKE_CUSTOMER_II["email"]
+		)
+		idempotency_key_action = "customer:create:{}".format(user.pk)
+		self.assertFalse(
+			IdempotencyKey.objects.filter(action=idempotency_key_action).exists()
+		)
+
+		customer, created = Customer.get_or_create(user)
+		self.assertTrue(IdempotencyKey.objects.filter(action=idempotency_key_action).exists())
+
+		with patch("stripe.Customer.retrieve", autospec=True):
+			customer.purge()
+
+		self.assertFalse(
+			IdempotencyKey.objects.filter(action=idempotency_key_action).exists()
+		)
+
 	@patch("stripe.Customer.retrieve", autospec=True)
 	def test_customer_delete_same_as_purge(self, customer_retrieve_fake):
 		self.customer.delete()