Repository: dj-stripe/dj-stripe

Issue:
Dj-stripe crashes on invoiceitem.updated event
An exception is raised when adding an invoice item to a Customer and invoicing 'now'.

Three webhook events are received: `invoiceitem.created`, `invoiceitem.updated` and `invoice.created`.

The second event is not handled properly as seen in the following screenshot:

![image](https://user-images.githubusercontent.com/73274/53709219-d331c380-3e15-11e9-8009-1b0418a14f20.png)

These are the details for the failing event:

event body:
```
{
  "id": "evt_1EA7XOB2v5yxQy40zDvVlVzI",
  "object": "event",
  "api_version": "2019-02-19",
  "created": 1551670342,
  "data": {
    "object": {
      "id": "ii_1EA7WXB2v5yxQy40WcnbH6OB",
      "object": "invoiceitem",
      "amount": 2000,
      "currency": "eur",
      "customer": "cus_EYU3FUAp0DUV0V",
      "date": 1551670289,
      "description": "test2",
      "discountable": true,
      "invoice": "in_1EA7XNB2v5yxQy40K9ACy0hT",
      "livemode": false,
      "metadata": {
      },
      "period": {
        "end": 1551670289,
        "start": 1551670289
      },
      "plan": null,
      "proration": false,
      "quantity": 1,
      "subscription": null,
      "unit_amount": 2000
    },
    "previous_attributes": {
      "invoice": null
    }
  },
  "livemode": false,
  "pending_webhooks": 2,
  "request": {
    "id": "req_RJrSPsumlzWd5m",
    "idempotency_key": null
  },
  "type": "invoiceitem.updated"
}
```

traceback:

```
Traceback (most recent call last):
  File "/home/.../lib/python3.5/site-packages/djstripe/models/webhooks.py", line 100, in from_request
    obj.process(save=False)
  File "/home/.../lib/python3.5/site-packages/djstripe/models/webhooks.py", line 179, in process
    self.event = Event.process(self.json_body)
  File "/home/.../lib/python3.5/site-packages/djstripe/models/core.py", line 1143, in process
    ret.invoke_webhook_handlers()
  File "/home/.../lib/python3.5/site-packages/djstripe/models/core.py", line 1155, in invoke_webhook_handlers
    webhooks.call_handlers(event=self)
  File "/home/.../lib/python3.5/site-packages/djstripe/webhooks.py", line 96, in call_handlers
    handler_func(event=event)
  File "/home/.../lib/python3.5/site-packages/djstripe/event_handlers.py", line 138, in other_object_webhook_handler
    _handle_crud_like_event(target_cls=target_cls, event=event)
  File "/home/.../lib/python3.5/site-packages/djstripe/event_handlers.py", line 264, in _handle_crud_like_event
    obj = target_cls.sync_from_stripe_data(data)
  File "/home/.../lib/python3.5/site-packages/djstripe/models/billing.py", line 628, in sync_from_stripe_data
    Invoice.sync_from_stripe_data(data={"invoice": invoice_data}, field_name="invoice")
  File "/home/.../lib/python3.5/site-packages/djstripe/models/base.py", line 567, in sync_from_stripe_data
    instance._sync(cls._stripe_object_to_record(data))
  File "/home/.../lib/python3.5/site-packages/djstripe/models/base.py", line 165, in _stripe_object_to_record
    raise ValueError("Stripe data has no `object` value. Aborting. %r" % (data))
ValueError: Stripe data has no `object` value. Aborting. {'forgiven': False, 'closed': False, 'invoice': 'in_1EA7XNB2v5yxQy40K9ACy0hT'}
```


# Steps to reproduce

- Choose a customer in the Stripe dashboard
- Add an invoice item
- Click on Invoice now



Discussion:
Thanks for the report, I can reproduce the same bug.

Resolving patch:
diff --git a/djstripe/models/base.py b/djstripe/models/base.py
--- a/djstripe/models/base.py
+++ b/djstripe/models/base.py
@@ -200,6 +200,25 @@ def _stripe_object_to_record(cls, data, current_ids=None, pending_relations=None
 
 		return result
 
+	@classmethod
+	def _id_from_data(cls, data):
+		"""
+		Extract stripe id from stripe field data
+		:param data:
+		:return:
+		"""
+
+		if isinstance(data, str):
+			# data like "sub_6lsC8pt7IcFpjA"
+			id_ = data
+		elif data:
+			# data like {"id": sub_6lsC8pt7IcFpjA", ...}
+			id_ = data.get("id")
+		else:
+			id_ = None
+
+		return id_
+
 	@classmethod
 	def _stripe_object_field_to_foreign_key(
 		cls, field, manipulated_data, current_ids=None, pending_relations=None
@@ -225,24 +244,23 @@ def _stripe_object_field_to_foreign_key(
 		skip = False
 
 		if issubclass(field.related_model, StripeModel):
-			# see also similar logic in _get_or_create_from_stripe_object
-			if isinstance(raw_field_data, str):
+			id_ = cls._id_from_data(raw_field_data)
+
+			if not raw_field_data:
+				skip = True
+			elif id_ == raw_field_data:
 				# A field like {"subscription": "sub_6lsC8pt7IcFpjA", ...}
-				id = raw_field_data
 				refetch = True
-			elif raw_field_data:
-				# A field like {"subscription": {"id": sub_6lsC8pt7IcFpjA", ...}}
-				id = raw_field_data.get("id")
 			else:
-				id = None
-				skip = True
+				# A field like {"subscription": {"id": sub_6lsC8pt7IcFpjA", ...}}
+				pass
 
-			if id in current_ids:
+			if id_ in current_ids:
 				# this object is currently being fetched, don't try to fetch again, to avoid recursion
 				# instead, record the relation that should be be created once "object_id" object exists
 				if pending_relations is not None:
 					object_id = manipulated_data["id"]
-					pending_relations.append((object_id, field, id))
+					pending_relations.append((object_id, field, id_))
 				skip = True
 
 			if not skip:
@@ -380,30 +398,30 @@ def _get_or_create_from_stripe_object(
 		if pending_relations is None:
 			pending_relations = []
 
-		if isinstance(field, str):
+		id_ = cls._id_from_data(field)
+
+		if not field:
+			# An empty field - We need to return nothing here because there is
+			# no way of knowing what needs to be fetched!
+			# TODO - this path shouldn't be hit, log/assert/exception?
+			return None, False
+		elif id_ == field:
 			# A field like {"subscription": "sub_6lsC8pt7IcFpjA", ...}
-			id = field
 			# We'll have to expand if the field is not "id" (= is nested)
 			should_expand = is_nested_data
-		elif field:
+		else:
 			# A field like {"subscription": {"id": sub_6lsC8pt7IcFpjA", ...}}
 			data = field
-			id = field.get("id")
-		else:
-			# An empty field - We need to return nothing here because there is
-			# no way of knowing what needs to be fetched!
-			# TODO - this path shouldn't be hit, log/assert/exception?
-			return None, False
 
 		try:
-			return cls.stripe_objects.get(id=id), False
+			return cls.stripe_objects.get(id=id_), False
 		except cls.DoesNotExist:
 			if is_nested_data and refetch:
 				# This is what `data` usually looks like:
 				# {"id": "cus_XXXX", "default_source": "card_XXXX"}
 				# Leaving the default field_name ("id") will get_or_create the customer.
 				# If field_name="default_source", we get_or_create the card instead.
-				cls_instance = cls(id=id)
+				cls_instance = cls(id=id_)
 				data = cls_instance.api_retrieve()
 				should_expand = False
 
@@ -424,7 +442,7 @@ def _get_or_create_from_stripe_object(
 				True,
 			)
 		except IntegrityError:
-			return cls.stripe_objects.get(id=id), False
+			return cls.stripe_objects.get(id=id_), False
 
 	@classmethod
 	def _stripe_object_to_customer(cls, target_cls, data):
@@ -557,7 +575,7 @@ def sync_from_stripe_data(cls, data, field_name="id"):
 
 		if data.get(field_name, None):
 			# stop nested objects from trying to retrieve this object before initial sync is complete
-			current_ids.add(data.get(field_name))
+			current_ids.add(cls._id_from_data(data.get(field_name)))
 
 		instance, created = cls._get_or_create_from_stripe_object(
 			data, field_name=field_name, current_ids=current_ids
diff --git a/djstripe/models/billing.py b/djstripe/models/billing.py
--- a/djstripe/models/billing.py
+++ b/djstripe/models/billing.py
@@ -624,8 +624,13 @@ def sync_from_stripe_data(cls, data, field_name="id"):
 		invoice_data = data.get("invoice")
 
 		if invoice_data:
-			# sync the Invoice first to avoid recursive Charge/Invoice loop
-			Invoice.sync_from_stripe_data(data={"invoice": invoice_data}, field_name="invoice")
+			# sync the Invoice first if it doesn't yet exist in our DB to avoid recursive Charge/Invoice loop
+			invoice_id = cls._id_from_data(invoice_data)
+			if not Invoice.objects.filter(id=invoice_id).exists():
+				if invoice_id == invoice_data:
+					# we only have the id, fetch the full data
+					invoice_data = Invoice(id=invoice_id).api_retrieve()
+				Invoice.sync_from_stripe_data(data=invoice_data)
 
 		return super().sync_from_stripe_data(data, field_name=field_name)
 


Test patch:
diff --git a/tests/test_invoiceitem.py b/tests/test_invoiceitem.py
--- a/tests/test_invoiceitem.py
+++ b/tests/test_invoiceitem.py
@@ -7,6 +7,7 @@
 from django.test.testcases import TestCase
 
 from djstripe.models import InvoiceItem
+from djstripe.settings import STRIPE_SECRET_KEY
 
 from . import (
 	FAKE_CHARGE_II, FAKE_CUSTOMER_II, FAKE_INVOICE_II, FAKE_INVOICEITEM,
@@ -70,19 +71,69 @@ def test_sync_with_subscription(
 		invoiceitem_data.update({"subscription": FAKE_SUBSCRIPTION_III["id"]})
 		invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data)
 
-		self.assert_fks(
-			invoiceitem,
-			expected_blank_fks={
-				"djstripe.Account.business_logo",
-				"djstripe.Charge.dispute",
-				"djstripe.Charge.transfer",
-				"djstripe.Customer.coupon",
-				"djstripe.Customer.subscriber",
-				"djstripe.Plan.product",
-				"djstripe.InvoiceItem.plan",
-			},
+		expected_blank_fks = {
+			"djstripe.Account.business_logo",
+			"djstripe.Charge.dispute",
+			"djstripe.Charge.transfer",
+			"djstripe.Customer.coupon",
+			"djstripe.Customer.subscriber",
+			"djstripe.Plan.product",
+			"djstripe.InvoiceItem.plan",
+		}
+
+		self.assert_fks(invoiceitem, expected_blank_fks=expected_blank_fks)
+
+		# Coverage of sync of existing data
+		invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data)
+
+		self.assert_fks(invoiceitem, expected_blank_fks=expected_blank_fks)
+
+		invoice_retrieve_mock.assert_called_once_with(
+			api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_INVOICE_II["id"]
 		)
 
+	@patch("djstripe.models.Account.get_default_account")
+	@patch("stripe.Subscription.retrieve", return_value=deepcopy(FAKE_SUBSCRIPTION_III))
+	@patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER_II))
+	@patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE_II))
+	@patch("stripe.Invoice.retrieve")
+	def test_sync_expanded_invoice_with_subscription(
+		self,
+		invoice_retrieve_mock,
+		charge_retrieve_mock,
+		customer_retrieve_mock,
+		subscription_retrieve_mock,
+		default_account_mock,
+	):
+		default_account_mock.return_value = self.account
+
+		invoiceitem_data = deepcopy(FAKE_INVOICEITEM)
+		# Expand the Invoice data
+		invoiceitem_data.update(
+			{
+				"subscription": FAKE_SUBSCRIPTION_III["id"],
+				"invoice": deepcopy(dict(FAKE_INVOICE_II)),
+			}
+		)
+		invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data)
+
+		expected_blank_fks = {
+			"djstripe.Account.business_logo",
+			"djstripe.Charge.dispute",
+			"djstripe.Charge.transfer",
+			"djstripe.Customer.coupon",
+			"djstripe.Customer.subscriber",
+			"djstripe.Plan.product",
+			"djstripe.InvoiceItem.plan",
+		}
+
+		self.assert_fks(invoiceitem, expected_blank_fks=expected_blank_fks)
+
+		# Coverage of sync of existing data
+		invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data)
+
+		self.assert_fks(invoiceitem, expected_blank_fks=expected_blank_fks)
+
 	@patch("djstripe.models.Account.get_default_account")
 	@patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN_II))
 	@patch("stripe.Subscription.retrieve", return_value=deepcopy(FAKE_SUBSCRIPTION_III))