Repository: dj-stripe/dj-stripe

Issue:
PaymentMethod event incorrect behavior
**Describe the bug**
I encountered a new bug related to new models (#914 ) in current master.
At least `payment_method.*`-events are not working correctly with the general crud-style handling.

The payload can contain ie. card id instead of real PaymentMethod IDs and that means you can't currently retrieve them through stripe `/v1/payment_methods/:id`-api.

I also reported that to stripe (related IRC conversation: https://freenode.logbot.info/stripe/20190903#c2618918) because I think it's bug in implementation of either the events or then the API

Anyway the current implementation indicates that we should check out all the events related to those new models if they really are doing what they supposed to.
Preferably with real production environment, the data is not like in the documentation apparently.

**Example event causing problems**
```
{
  "id": "evt_1FEiXXXXXXXX",
  "object": "event",
  "api_version": "2018-11-08",
  "created": 1567541994,
  "data": {
    "object": {
      "id": "card_1DXXXXXXXXXXX",
      "object": "payment_method",
      "billing_details": {
        "address": {
          "city": null,
          "country": null,
          "line1": null,
          "line2": null,
          "postal_code": null,
          "state": null
        },
        "email": null,
        "name": "Charles Customer",
        "phone": null
      },
      "card": {
        "brand": "visa",
        "checks": {
          "address_line1_check": null,
          "address_postal_code_check": null,
          "cvc_check": null
        },
        "country": "FR",
        "exp_month": 8,
        "exp_year": 2022,
        "fingerprint": "xxxxx",
        "funding": "debit",
        "generated_from": null,
        "last4": "2222",
        "three_d_secure_usage": {
          "supported": true
        },
        "wallet": null
      },
      "created": 1545493504,
      "customer": "cus_ECbxxxxxxxxxx",
      "livemode": true,
      "metadata": {
      },
      "type": "card"
    }
  },
  "livemode": true,
  "pending_webhooks": 1,
  "request": {
    "id": "req_j9PAxxxxxxxxx",
    "idempotency_key": null
  },
  "type": "payment_method.detached"
}
```
**Expected behavior**
Events should be handled with more specific handlers.

**Environment**
- dj-stripe version: [e.g. master at 0298f21e456b25232a6e47199db14cee18c9cfc1]

**Can you reproduce the issue with the latest version of master?**

Yes




Discussion:
Thanks for the report and for chasing up with Stripe devs, it looks like we'll need to put in some special handling unless Stripe fix it on their side.

I'll take a look at this but might bump it to the 2.1.x release.
I notice that your example is for a payment_method.detached event, I *think* at least part the problem here is that the old-style Card objects can't actually be detached in the same way as Source or PaymentMethod - they're deleted on the Stripe side when they're removed - I suspect that's probably confusing some things where Stripe is wrapping cards in a PaymentMethod.

I'm not sure if that's the whole problem though, but bumping this to 2.1.x release.
My server has been spitting our errors all the time ever since updating to rc0. Real pain in the ass. :|

I accidentally opened a duplicate at #1005. I closed it now, though you may find some of the details I put there useful.
Any updates on this one? I'm getting so many errors from my server still, it's quite overwhelming. :(
I've not had a chance to try a fix yet.

Is it only event type= "payment_method.detached" with   ```"object": { "id": "card_*``` that's causing errors, or is it a wider range of events?  Or a wider range of object ids?
For what I've seen, only this.
Thanks.  So yeah my hunch is that fix will be to special case this event in event_handlers.py, and only run the normal CRUD handlers if the id prefix isn't for legacy payment type (`card_` ) ~~/ `ba_` (bank account)~~).
Looking at this now, debug notes follow:

Creating a card through the stripe dashboard triggers the following events:

payment_method.attached   (object = `payment_method`, id = `card_xxx`)
customer.source.created   (object = `card`, id = `card_xxx`)
setup_intent.created (object = `setup_intent`, id = `seti_yyy`, payment_method = `card_xxx`)
setup_intent.succeeded (object = `setup_intent`, id = `seti_yyy`, payment_method = `card_xxx`)

Between them we end up with entries with id=`card_xxx` in djstripe_paymentmethod and djstripe_card.

---

Removing a card through the stripe dashboard triggers:

customer.source.deleted (object = `card`, id = `card_xxx`)
payment_method.detached (object = `payment_method` , id = `card_xxx`) , which hits the exception.

(so the card is deleted from djstripe_card but not from djstripe_paymentmethod ) 

---

Incidentally bank account doesn't seem to be treated as a payment_method, so I think this issue affects card only.
Also, the stripe PaymentMethod API does allow you to see cards - eg this works as expected : `djstripe.models.PaymentMethod(id="card_xxx").api_retrieve()`

I think my previous comment is right, the problem is that `card`s are deleted by Stripe on removal, and therefore the retrieve we do in `_handle_crud_like_event` fails.

Resolving patch:
diff --git a/djstripe/event_handlers.py b/djstripe/event_handlers.py
--- a/djstripe/event_handlers.py
+++ b/djstripe/event_handlers.py
@@ -114,6 +114,35 @@ def customer_subscription_webhook_handler(event):
     _handle_crud_like_event(target_cls=models.Subscription, event=event)
 
 
+@webhooks.handler("payment_method")
+def payment_method_handler(event):
+    """
+    Handle updates to payment_method objects
+    :param event:
+    :return:
+
+    Docs for:
+    - payment_method: https://stripe.com/docs/api/payment_methods
+    """
+    id_ = event.data.get("object", {}).get("id", None)
+
+    if (
+        event.parts == ["payment_method", "detached"]
+        and id_
+        and id_.startswith("card_")
+    ):
+        # Special case to handle a quirk in stripe's wrapping of legacy "card" objects
+        # with payment_methods - card objects are deleted on detach, so treat this as
+        # a delete event
+        _handle_crud_like_event(
+            target_cls=models.PaymentMethod,
+            event=event,
+            crud_type=CrudType(deleted=True),
+        )
+    else:
+        _handle_crud_like_event(target_cls=models.PaymentMethod, event=event)
+
+
 @webhooks.handler(
     "transfer",
     "charge",
@@ -121,7 +150,6 @@ def customer_subscription_webhook_handler(event):
     "invoice",
     "invoiceitem",
     "payment_intent",
-    "payment_method",
     "plan",
     "product",
     "setup_intent",
@@ -129,8 +157,8 @@ def customer_subscription_webhook_handler(event):
 )
 def other_object_webhook_handler(event):
     """
-    Handle updates to transfer, charge, invoice, invoiceitem, plan, product
-    and source objects.
+    Handle updates to transfer, charge, coupon, invoice, invoiceitem, payment_intent,
+    plan, product, setup_intent and source objects.
 
     Docs for:
     - charge: https://stripe.com/docs/api#charges
@@ -140,7 +168,6 @@ def other_object_webhook_handler(event):
     - plan: https://stripe.com/docs/api#plans
     - product: https://stripe.com/docs/api#products
     - source: https://stripe.com/docs/api#sources
-    - payment_method: https://stripe.com/docs/api/payment_methods
     - payment_intent: https://stripe.com/docs/api/payment_intents
     """
 
@@ -155,7 +182,6 @@ def other_object_webhook_handler(event):
             "invoice": models.Invoice,
             "invoiceitem": models.InvoiceItem,
             "payment_intent": models.PaymentIntent,
-            "payment_method": models.PaymentMethod,
             "plan": models.Plan,
             "product": models.Product,
             "transfer": models.Transfer,


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1463,6 +1463,45 @@ def pay(self):
     "type": "payment_method.attached",
 }
 
+FAKE_EVENT_PAYMENT_METHOD_DETACHED = {
+    "id": "evt_1FDOwDKatMEEd998o5Fdadfds",
+    "object": "event",
+    "api_version": "2019-08-14",
+    "created": 1567228549,
+    "data": {"object": deepcopy(FAKE_PAYMENT_METHOD_I)},
+    "livemode": False,
+    "pending_webhooks": 0,
+    "request": {"id": "req_9c9djVqxcxgdfg", "idempotency_key": None},
+    "type": "payment_method.detached",
+}
+FAKE_EVENT_PAYMENT_METHOD_DETACHED["data"]["object"]["customer"] = None
+
+FAKE_EVENT_CARD_PAYMENT_METHOD_ATTACHED = {
+    "id": "evt_1FDOwDKatMEEd998o5Fghgfh",
+    "object": "event",
+    "api_version": "2019-08-14",
+    "created": 1567228549,
+    "data": {"object": deepcopy(FAKE_CARD_AS_PAYMENT_METHOD)},
+    "livemode": False,
+    "pending_webhooks": 0,
+    "request": {"id": "req_9c9djVqxUhgfh", "idempotency_key": None},
+    "type": "payment_method.attached",
+}
+
+FAKE_EVENT_CARD_PAYMENT_METHOD_DETACHED = {
+    "id": "evt_1FDOwDKatMEEd998o5435345",
+    "object": "event",
+    "api_version": "2019-08-14",
+    "created": 1567228549,
+    "data": {"object": deepcopy(FAKE_CARD_AS_PAYMENT_METHOD)},
+    "livemode": False,
+    "pending_webhooks": 0,
+    "request": {"id": "req_9c9djVqx6tgeg", "idempotency_key": None},
+    "type": "payment_method.detached",
+}
+# Note that the event from Stripe doesn't have customer = None
+
+
 FAKE_EVENT_PLAN_CREATED = {
     "id": "evt_1877X72eZvKYlo2CLK6daFxu",
     "object": "event",
diff --git a/tests/test_event_handlers.py b/tests/test_event_handlers.py
--- a/tests/test_event_handlers.py
+++ b/tests/test_event_handlers.py
@@ -7,6 +7,7 @@
 
 from django.contrib.auth import get_user_model
 from django.test import TestCase
+from stripe.error import InvalidRequestError
 
 from djstripe.models import (
     Card,
@@ -36,6 +37,8 @@
     FAKE_CUSTOMER_II,
     FAKE_DISPUTE,
     FAKE_EVENT_ACCOUNT_APPLICATION_DEAUTHORIZED,
+    FAKE_EVENT_CARD_PAYMENT_METHOD_ATTACHED,
+    FAKE_EVENT_CARD_PAYMENT_METHOD_DETACHED,
     FAKE_EVENT_CHARGE_SUCCEEDED,
     FAKE_EVENT_CUSTOMER_CREATED,
     FAKE_EVENT_CUSTOMER_DELETED,
@@ -54,6 +57,7 @@
     FAKE_EVENT_INVOICEITEM_DELETED,
     FAKE_EVENT_PAYMENT_INTENT_SUCCEEDED_DESTINATION_CHARGE,
     FAKE_EVENT_PAYMENT_METHOD_ATTACHED,
+    FAKE_EVENT_PAYMENT_METHOD_DETACHED,
     FAKE_EVENT_PLAN_CREATED,
     FAKE_EVENT_PLAN_DELETED,
     FAKE_EVENT_PLAN_REQUEST_IS_OBJECT,
@@ -802,6 +806,88 @@ def test_payment_method_attached(
             },
         )
 
+    @patch("stripe.PaymentMethod.retrieve", autospec=True)
+    @patch("stripe.Event.retrieve", autospec=True)
+    def test_card_payment_method_attached(
+        self, event_retrieve_mock, payment_method_retrieve_mock
+    ):
+        # Attach of a legacy id="card_xxx" payment method should behave exactly
+        # as per a normal "native" id="pm_yyy" payment_method.
+        fake_stripe_event = deepcopy(FAKE_EVENT_CARD_PAYMENT_METHOD_ATTACHED)
+        event_retrieve_mock.return_value = fake_stripe_event
+        payment_method_retrieve_mock.return_value = fake_stripe_event["data"]["object"]
+
+        event = Event.sync_from_stripe_data(fake_stripe_event)
+        event.invoke_webhook_handlers()
+
+        payment_method = PaymentMethod.objects.get(
+            id=fake_stripe_event["data"]["object"]["id"]
+        )
+
+        self.assert_fks(
+            payment_method,
+            expected_blank_fks={
+                "djstripe.Customer.coupon",
+                "djstripe.Customer.default_payment_method",
+            },
+        )
+
+    @patch("stripe.PaymentMethod.retrieve", autospec=True)
+    @patch("stripe.Event.retrieve", autospec=True)
+    def test_payment_method_detached(
+        self, event_retrieve_mock, payment_method_retrieve_mock
+    ):
+        fake_stripe_event = deepcopy(FAKE_EVENT_PAYMENT_METHOD_DETACHED)
+        event_retrieve_mock.return_value = fake_stripe_event
+        payment_method_retrieve_mock.return_value = fake_stripe_event["data"]["object"]
+
+        event = Event.sync_from_stripe_data(fake_stripe_event)
+        event.invoke_webhook_handlers()
+
+        payment_method = PaymentMethod.objects.get(
+            id=fake_stripe_event["data"]["object"]["id"]
+        )
+
+        self.assertIsNone(
+            payment_method.customer,
+            "Detach of a payment_method should set customer to null",
+        )
+
+        self.assert_fks(
+            payment_method, expected_blank_fks={"djstripe.PaymentMethod.customer"}
+        )
+
+    @patch(
+        "stripe.PaymentMethod.retrieve",
+        side_effect=InvalidRequestError(
+            message="No such payment_method: card_xxxx",
+            param="payment_method",
+            code="resource_missing",
+        ),
+        autospec=True,
+    )
+    @patch("stripe.Event.retrieve", autospec=True)
+    def test_card_payment_method_detached(
+        self, event_retrieve_mock, payment_method_retrieve_mock
+    ):
+        # Detach of a legacy id="card_xxx" payment method is handled specially,
+        # since the card is deleted by Stripe and therefore PaymetMethod.retrieve fails
+
+        fake_stripe_event = deepcopy(FAKE_EVENT_CARD_PAYMENT_METHOD_DETACHED)
+        event_retrieve_mock.return_value = fake_stripe_event
+        payment_method_retrieve_mock.return_value = fake_stripe_event["data"]["object"]
+
+        event = Event.sync_from_stripe_data(fake_stripe_event)
+        event.invoke_webhook_handlers()
+
+        self.assertEqual(
+            PaymentMethod.objects.filter(
+                id=fake_stripe_event["data"]["object"]["id"]
+            ).count(),
+            0,
+            "Detach of a 'card_' payment_method should delete it",
+        )
+
 
 class TestTransferEvents(EventTestCase):
     @patch("stripe.Transfer.retrieve", autospec=True)