Repository: dj-stripe/dj-stripe

Issue:
PaymentMethod detach is missing and attach is awkward
**Describe the bug**
I'm using 4a828a48092a3904094917776ce725ec9aa3fce5 (after the #914 merge) and trying to set up an SCA complaint flow.
While there's a `PaymentMethod.attach` function to attach a payment method to a customer, it's awkward for a couple of reasons:
1. It should be an object method, not a class method. The way it is now, it's just a thin wrapper around the Stripe method.
2. It shouldn't accept a stripe_customer, but either a djstripe customer, or a customer id, or better yet, either, like many places in the API.
The object is also missing the equivalent `detach` method, to detach it from a customer.

**To Reproduce**
Look at the API.

**Expected behavior**
API to be consistent with the rest of the djstripe API and add missing needed functionality (detach).

**Environment**
- dj-stripe version: master at 4a828a48092a3904094917776ce725ec9aa3fce5
- Your Stripe account's default API version: [e.g. 2019-02-19 - shown as "default" on https://dashboard.stripe.com/developers]
- Database: Postgres
- Python version: 3.7.4
- Django version: 2.2.4

**Can you reproduce the issue with the latest version of master?**

Yes

**Additional context**
Add any other context about the problem here.



Discussion:
I've partly fixed the PaymentMethod.attach issues in #936 - it's still a classmethod, but if you have a Customer object you can you can use customer.add_payment_method(pm) so I think that's OK.
I might bump the PaymentMethod.detach part of this this to next minor release (2.1.x) since I don't think it's a blocker on 2.1.0 release.

Resolving patch:
diff --git a/djstripe/models/payment_methods.py b/djstripe/models/payment_methods.py
--- a/djstripe/models/payment_methods.py
+++ b/djstripe/models/payment_methods.py
@@ -576,3 +576,43 @@ def attach(
             payment_method, customer=customer, **extra_kwargs
         )
         return cls.sync_from_stripe_data(stripe_payment_method)
+
+    def detach(self):
+        """
+        Detach the payment method from its customer.
+
+        :return: Returns true if the payment method was newly detached, \
+                 false if it was already detached
+        :rtype: bool
+        """
+        # Find customers that use this
+        customers = Customer.objects.filter(default_payment_method=self).all()
+        changed = True
+
+        # special handling is needed for legacy "card"-type PaymentMethods,
+        # since detaching them deletes them within Stripe.
+        # see https://github.com/dj-stripe/dj-stripe/pull/967
+        is_legacy_card = self.id.startswith("card_")
+
+        try:
+            self.sync_from_stripe_data(self.api_retrieve().detach())
+
+            # resync customer to update .default_payment_method and
+            # .invoice_settings.default_payment_method
+            for customer in customers:
+                Customer.sync_from_stripe_data(customer.api_retrieve())
+
+        except (InvalidRequestError,):
+            # The source was already detached. Resyncing.
+
+            if self.pk and not is_legacy_card:
+                self.sync_from_stripe_data(self.api_retrieve())
+            changed = False
+
+        if self.pk:
+            if is_legacy_card:
+                self.delete()
+            else:
+                self.refresh_from_db()
+
+        return changed


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -269,11 +269,6 @@ class CardDict(LegacySourceDict):
 
 FAKE_CARD = CardDict(load_fixture("card_card_fakefakefakefakefake0001.json"))
 
-# FAKE_CARD, but accessed as a PaymentMethod
-FAKE_CARD_AS_PAYMENT_METHOD = load_fixture(
-    "payment_method_card_fakefakefakefakefake0001.json"
-)
-
 FAKE_CARD_II = CardDict(load_fixture("card_card_fakefakefakefakefake0002.json"))
 
 FAKE_CARD_III = CardDict(
@@ -398,7 +393,23 @@ def detach(self):
 
 
 FAKE_PAYMENT_INTENT_I = load_fixture("payment_intent_pi_fakefakefakefakefake0001.json")
-FAKE_PAYMENT_METHOD_I = load_fixture("payment_method_pm_fakefakefakefake0001.json")
+
+
+class PaymentMethodDict(dict):
+    def detach(self):
+        self.pop("customer")
+        return self
+
+
+FAKE_PAYMENT_METHOD_I = PaymentMethodDict(
+    load_fixture("payment_method_pm_fakefakefakefake0001.json")
+)
+
+# FAKE_CARD, but accessed as a PaymentMethod
+FAKE_CARD_AS_PAYMENT_METHOD = PaymentMethodDict(
+    load_fixture("payment_method_card_fakefakefakefakefake0001.json")
+)
+
 
 # TODO - add to regenerate_test_fixtures and replace this with a JSON fixture
 FAKE_SETUP_INTENT_I = {
diff --git a/tests/test_payment_method.py b/tests/test_payment_method.py
--- a/tests/test_payment_method.py
+++ b/tests/test_payment_method.py
@@ -1,20 +1,25 @@
 """
 dj-stripe PaymenthMethod Model Tests.
 """
+import sys
 from copy import deepcopy
 from unittest.mock import patch
 
 from django.contrib.auth import get_user_model
 from django.test import TestCase
-from tests import (
+from stripe.error import InvalidRequestError
+
+from djstripe.models import PaymentMethod
+
+from . import (
+    FAKE_CARD_AS_PAYMENT_METHOD,
     FAKE_CUSTOMER,
     FAKE_PAYMENT_METHOD_I,
     AssertStripeFksMixin,
+    PaymentMethodDict,
     default_account,
 )
 
-from djstripe.models import PaymentMethod
-
 
 class PaymentMethodTest(AssertStripeFksMixin, TestCase):
     def setUp(self):
@@ -79,6 +84,135 @@ def test_attach_synced(self, attach_mock):
             },
         )
 
+    def test_detach(self):
+        original_detach = PaymentMethodDict.detach
+
+        def mocked_detach(*args, **kwargs):
+            return original_detach(*args, **kwargs)
+
+        with patch(
+            "stripe.PaymentMethod.retrieve",
+            return_value=deepcopy(FAKE_PAYMENT_METHOD_I),
+            autospec=True,
+        ):
+            PaymentMethod.sync_from_stripe_data(deepcopy(FAKE_PAYMENT_METHOD_I))
+
+        self.assertEqual(1, self.customer.payment_methods.count())
+
+        payment_method = self.customer.payment_methods.first()
+
+        with patch(
+            "tests.PaymentMethodDict.detach", side_effect=mocked_detach, autospec=True
+        ) as mock_detach, patch(
+            "stripe.PaymentMethod.retrieve",
+            return_value=deepcopy(FAKE_PAYMENT_METHOD_I),
+            autospec=True,
+        ):
+            self.assertTrue(payment_method.detach())
+
+        self.assertEqual(0, self.customer.payment_methods.count())
+        self.assertIsNone(self.customer.default_payment_method)
+
+        self.assertIsNone(payment_method.customer)
+
+        if sys.version_info >= (3, 6):
+            # this mock isn't working on py34, py35, but it's not strictly necessary
+            # for the test
+            mock_detach.assert_called()
+
+        self.assert_fks(
+            payment_method, expected_blank_fks={"djstripe.PaymentMethod.customer"}
+        )
+
+        with patch(
+            "tests.PaymentMethodDict.detach",
+            side_effect=InvalidRequestError(
+                message="A source must be attached to a customer to be used "
+                "as a `payment_method`",
+                param="payment_method",
+            ),
+            autospec=True,
+        ) as mock_detach, patch(
+            "stripe.PaymentMethod.retrieve",
+            return_value=deepcopy(FAKE_PAYMENT_METHOD_I),
+            autospec=True,
+        ) as payment_method_retrieve_mock:
+            payment_method_retrieve_mock.return_value["customer"] = None
+
+            self.assertFalse(
+                payment_method.detach(), "Second call to detach should return false"
+            )
+
+    def test_detach_card(self):
+        original_detach = PaymentMethodDict.detach
+
+        # "card_" payment methods are deleted after detach
+        deleted_card_exception = InvalidRequestError(
+            message="No such payment_method: card_xxxx",
+            param="payment_method",
+            code="resource_missing",
+        )
+
+        def mocked_detach(*args, **kwargs):
+            return original_detach(*args, **kwargs)
+
+        with patch(
+            "stripe.PaymentMethod.retrieve",
+            return_value=deepcopy(FAKE_CARD_AS_PAYMENT_METHOD),
+            autospec=True,
+        ):
+            PaymentMethod.sync_from_stripe_data(deepcopy(FAKE_CARD_AS_PAYMENT_METHOD))
+
+        self.assertEqual(1, self.customer.payment_methods.count())
+
+        payment_method = self.customer.payment_methods.first()
+
+        self.assertTrue(
+            payment_method.id.startswith("card_"), "We expect this to be a 'card_'"
+        )
+
+        with patch(
+            "tests.PaymentMethodDict.detach", side_effect=mocked_detach, autospec=True
+        ) as mock_detach, patch(
+            "stripe.PaymentMethod.retrieve",
+            return_value=deepcopy(FAKE_CARD_AS_PAYMENT_METHOD),
+            autospec=True,
+        ):
+            self.assertTrue(payment_method.detach())
+
+        self.assertEqual(0, self.customer.payment_methods.count())
+        self.assertIsNone(self.customer.default_payment_method)
+
+        self.assertEqual(
+            PaymentMethod.objects.filter(id=payment_method.id).count(),
+            0,
+            "We expect PaymentMethod id = card_* to be deleted",
+        )
+
+        if sys.version_info >= (3, 6):
+            # this mock isn't working on py34, py35, but it's not strictly necessary
+            # for the test
+            mock_detach.assert_called()
+
+        with patch(
+            "tests.PaymentMethodDict.detach",
+            side_effect=InvalidRequestError(
+                message="A source must be attached to a customer to be used "
+                "as a `payment_method`",
+                param="payment_method",
+            ),
+            autospec=True,
+        ) as mock_detach, patch(
+            "stripe.PaymentMethod.retrieve",
+            side_effect=deleted_card_exception,
+            autospec=True,
+        ) as payment_method_retrieve_mock:
+            payment_method_retrieve_mock.return_value["customer"] = None
+
+            self.assertFalse(
+                payment_method.detach(), "Second call to detach should return false"
+            )
+
     def test_sync_null_customer(self):
         payment_method = PaymentMethod.sync_from_stripe_data(
             deepcopy(FAKE_PAYMENT_METHOD_I)