Repository: dj-stripe/dj-stripe

Issue:
invoiceitem.created events cause an exception
As the title says.

Due to #833 I can't tell you the actual exception I got, but I later manually tried to call invoke_webhook_handlers and I got the following backtrace:

```
>>> event.invoke_webhook_handlers()
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/opt/apps/securesync/.venv/lib/python3.7/site-packages/djstripe/models/core.py", line 1153, in invoke_webhook_handlers
    webhooks.call_handlers(event=self)
  File "/opt/apps/securesync/.venv/lib/python3.7/site-packages/djstripe/webhooks.py", line 96, in call_handlers
    handler_func(event=event)
  File "/opt/apps/securesync/.venv/lib/python3.7/site-packages/djstripe/event_handlers.py", line 136, in other_object_webhook_handler
    _handle_crud_like_event(target_cls=target_cls, event=event)
  File "/opt/apps/securesync/.venv/lib/python3.7/site-packages/djstripe/event_handlers.py", line 262, in _handle_crud_like_event
    obj = target_cls.sync_from_stripe_data(data)
  File "/opt/apps/securesync/.venv/lib/python3.7/site-packages/djstripe/models/billing.py", line 626, in sync_from_stripe_data
    data={"invoice": data.get("invoice")}, field_name="invoice"
  File "/opt/apps/securesync/.venv/lib/python3.7/site-packages/djstripe/models/base.py", line 566, in sync_from_stripe_data
    instance._sync(cls._stripe_object_to_record(data))
AttributeError: 'NoneType' object has no attribute '_sync'

```
Event I got from stripe:

```
{
  "object": {
    "id": "ii_CENSORED",
    "object": "invoiceitem",
    "amount": -2138,
    "currency": "usd",
    "customer": "cus_CENSORED",
    "date": 1551005723,
    "description": "Unused time on Personal after 24 Feb 2019",
    "discountable": false,
    "invoice": null,
    "livemode": true,
    "metadata": {
    },
    "period": {
      "end": 1579099285,
      "start": 1551005723
    },
    "plan": {
      "id": "personal-yearly2",
      "object": "plan",
      "active": true,
      "aggregate_usage": null,
      "amount": 2400,
      "billing_scheme": "per_unit",
      "created": 1542748858,
      "currency": "usd",
      "interval": "year",
      "interval_count": 1,
      "livemode": true,
      "metadata": {
      },
      "nickname": "Yearly",
      "product": "prod_CENSORED",
      "tiers": null,
      "tiers_mode": null,
      "transform_usage": null,
      "trial_period_days": 14,
      "usage_type": "licensed"
    },
    "proration": true,
    "quantity": 1,
    "subscription": "sub_CENSORED",
    "subscription_item": "si_CENSORED",
    "unit_amount": -2138
  }
}
```

I don't think it matters, but this invoice item in particular was created automatically due to proration.


Discussion:
OK, so I think this is because ` _get_or_create_from_stripe_object` returned a `None` here:

https://github.com/dj-stripe/dj-stripe/blob/2fcbe9644c5166cfe41239cc04b4808dc3bf38d9/djstripe/models/base.py#L395

Because  `invoice = None`

Looks like a bug yeah, we should test that case.
I think having a null there means it's an invoiceitem for an invoice that is not yet created, which is quite common, and is essentially the case with every proration.

Resolving patch:
diff --git a/djstripe/models/base.py b/djstripe/models/base.py
--- a/djstripe/models/base.py
+++ b/djstripe/models/base.py
@@ -392,6 +392,7 @@ def _get_or_create_from_stripe_object(
 		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:
diff --git a/djstripe/models/billing.py b/djstripe/models/billing.py
--- a/djstripe/models/billing.py
+++ b/djstripe/models/billing.py
@@ -621,10 +621,11 @@ def _manipulate_stripe_object_hook(cls, data):
 
 	@classmethod
 	def sync_from_stripe_data(cls, data, field_name="id"):
-		# sync the Invoice first to avoid recursive Charge/Invoice loop
-		Invoice.sync_from_stripe_data(
-			data={"invoice": data.get("invoice")}, field_name="invoice"
-		)
+		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")
 
 		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
@@ -118,3 +118,42 @@ def test_sync_proration(
 				"djstripe.Plan.product",
 			},
 		)
+
+	@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))
+	@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_null_invoice(
+		self,
+		invoice_retrieve_mock,
+		charge_retrieve_mock,
+		customer_retrieve_mock,
+		subscription_retrieve_mock,
+		plan_retrieve_mock,
+		default_account_mock,
+	):
+		default_account_mock.return_value = self.account
+
+		invoiceitem_data = deepcopy(FAKE_INVOICEITEM)
+		invoiceitem_data.update(
+			{"proration": True, "plan": FAKE_PLAN_II["id"], "invoice": None}
+		)
+		invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data)
+
+		self.assertEqual(FAKE_PLAN_II["id"], invoiceitem.plan.id)
+
+		self.assert_fks(
+			invoiceitem,
+			expected_blank_fks={
+				"djstripe.Account.business_logo",
+				"djstripe.Charge.dispute",
+				"djstripe.Charge.transfer",
+				"djstripe.Customer.coupon",
+				"djstripe.Customer.subscriber",
+				"djstripe.InvoiceItem.invoice",
+				"djstripe.InvoiceItem.subscription",
+				"djstripe.Plan.product",
+			},
+		)