Repository: dj-stripe/dj-stripe

Issue:
subscriber_id will be empty for all new customers created from checkout.session.completed
**Describe the bug**
<!-- A clear and concise description of what the bug is. Make sure not to include private keys or live customer information. -->

`Djstripe` internally uses the `subscriber` key to map `customers` to `DJSTRIPE_SUBSCRIBER_MODEL`. This works fine as long as new `customers` are created using `Customer.get_or_create` or `Customer.create` class methods. Stripe, however, exposes another modality to create customers as well via `Stripe Checkout`. In case the user of `Djstripe` relies on getting the newly created customer from `checkout.session.completed` webhook the required key `subscriber_id` would be empty in the `customer.metadata` key since that is totally a `Djstripe` construct and has nothing to do with stripe.

Note: 

**Software versions**
- dj-stripe version: 2.5.1
- Python version: 3.8
- Django version: 3.1
- Stripe API version: 2.6
- Database type and version: PostgreSQL

**Steps To Reproduce**
<!-- Please tell us how to reproduce the issue. Use numbered steps. Make sure not to include private keys or live customer information. -->
1. Setup the `urls` and `views` for creating a `Stripe Checkout Session` and notice that the metadata key is empty.


**Can you reproduce the issue with the latest version of master?**
<!-- Yes or No -->
Yes

**Expected Behavior**
<!-- Please clearly and concisely describe what should happen after following the reproduction steps -->
After the `customer` is created in the db, the corresponding `DJSTRIPE_SUBSCRIBER_MODEL` is retrieved from the db and saved as `customer.subscriber`

**Actual Behavior**
<!-- Please clearly and concisely describe what actually happens after following the reproduction steps. Include a traceback if you have it, but ENSURE YOU REDACT IT  -->

A new `customer` is created with an empty `subscriber` key.



Discussion:

Resolving patch:
diff --git a/djstripe/event_handlers.py b/djstripe/event_handlers.py
--- a/djstripe/event_handlers.py
+++ b/djstripe/event_handlers.py
@@ -16,6 +16,10 @@
 import logging
 from enum import Enum
 
+from django.core.exceptions import ObjectDoesNotExist
+
+from djstripe.settings import djstripe_settings
+
 from . import models, webhooks
 from .enums import PayoutType, SourceType
 from .utils import convert_tstamp
@@ -23,6 +27,30 @@
 logger = logging.getLogger(__name__)
 
 
+def update_customer_helper(metadata, customer_id, subscriber_key):
+    """
+    A helper function that updates customer's subscriber and metadata fields
+    """
+
+    # only update customer.subscriber if both the customer and subscriber already exist
+    if (
+        subscriber_key not in ("", None)
+        and metadata.get(subscriber_key, "")
+        and customer_id
+    ):
+        try:
+            subscriber = djstripe_settings.get_subscriber_model().objects.get(
+                id=metadata.get(subscriber_key, "")
+            )
+            customer = models.Customer.objects.get(id=customer_id)
+            customer.subscriber = subscriber
+            customer.metadata = metadata
+            customer.save()
+
+        except ObjectDoesNotExist:
+            pass
+
+
 @webhooks.handler("customer")
 def customer_webhook_handler(event):
     """Handle updates to customer objects.
@@ -32,6 +60,9 @@ def customer_webhook_handler(event):
     As customers are tied to local users, djstripe will not create customers that
     do not already exist locally.
 
+    And updates to the subscriber model and metadata fields of customer if present
+    in checkout.sessions metadata key.
+
     Docs and an example customer webhook response:
     https://stripe.com/docs/api#customer_object
     """
@@ -41,8 +72,13 @@ def customer_webhook_handler(event):
 
     if event.customer and target_object_type == "customer":
 
-        # As customers are tied to local users, djstripe will not create
-        # customers that do not already exist locally.
+        metadata = event.data.get("object", {}).get("metadata", {})
+        customer_id = event.data.get("object", {}).get("id", "")
+        subscriber_key = djstripe_settings.SUBSCRIBER_CUSTOMER_KEY
+
+        # only update customer.subscriber if both the customer and subscriber already exist
+        update_customer_helper(metadata, customer_id, subscriber_key)
+
         _handle_crud_like_event(target_cls=models.Customer, event=event)
 
 
@@ -223,8 +259,27 @@ def dispute_webhook_handler(event):
         _handle_crud_like_event(target_cls=models.Dispute, event=event)
 
 
+@webhooks.handler("checkout")
+def checkout_webhook_handler(event):
+    """
+    Handle updates to Checkout Session objects
+    And updates to the subscriber model and metadata fields of customer if present
+    in checkout.sessions metadata key.
+
+    Please note djstripe doesn't create new subscriber and customer instances
+    - checkout: https://stripe.com/docs/api/checkout/sessions
+    """
+    metadata = event.data.get("object", {}).get("metadata", {})
+    customer_id = event.data.get("object", {}).get("customer", "")
+    subscriber_key = djstripe_settings.SUBSCRIBER_CUSTOMER_KEY
+
+    # only update customer.subscriber if both the customer and subscriber already exist
+    update_customer_helper(metadata, customer_id, subscriber_key)
+
+    _handle_crud_like_event(target_cls=models.Session, event=event)
+
+
 @webhooks.handler(
-    "checkout",
     "coupon",
     "file",
     "invoice",
@@ -241,12 +296,11 @@ def dispute_webhook_handler(event):
 )
 def other_object_webhook_handler(event):
     """
-    Handle updates to checkout, coupon, file, invoice, invoiceitem, payment_intent,
+    Handle updates to coupon, file, invoice, invoiceitem, payment_intent,
     plan, product, setup_intent, subscription_schedule, source, tax_rate
     and transfer objects.
 
     Docs for:
-    - checkout: https://stripe.com/docs/api/checkout/sessions
     - coupon: https://stripe.com/docs/api/coupons
     - file: https://stripe.com/docs/api/files
     - invoice: https://stripe.com/docs/api/invoices
@@ -263,7 +317,6 @@ def other_object_webhook_handler(event):
     """
 
     target_cls = {
-        "checkout": models.Session,
         "coupon": models.Coupon,
         "file": models.File,
         "invoice": models.Invoice,


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1864,6 +1864,10 @@ def pay(self):
     "type": "customer.created",
 }
 
+FAKE_EVENT_CUSTOMER_UPDATED = deepcopy(FAKE_EVENT_CUSTOMER_CREATED)
+FAKE_EVENT_CUSTOMER_UPDATED["type"] = "customer.updated"
+
+
 FAKE_EVENT_CUSTOMER_DELETED = deepcopy(FAKE_EVENT_CUSTOMER_CREATED)
 FAKE_EVENT_CUSTOMER_DELETED.update(
     {"id": "evt_38DHch3whaDvKYlo2jksfsFFxy", "type": "customer.deleted"}
diff --git a/tests/apps/example/views.py b/tests/apps/example/views.py
--- a/tests/apps/example/views.py
+++ b/tests/apps/example/views.py
@@ -26,6 +26,7 @@ class CreateCheckoutSessionView(TemplateView):
     Example View to demonstrate how to use dj-stripe to:
 
      * Create a Stripe Checkout Session (for a new customer)
+     * Add SUBSCRIBER_CUSTOMER_KEY to metadata to populate customer.subscriber model field
      * Fill out Payment Form and Complete Payment
 
     Redirects the User to Stripe Checkout Session.
@@ -49,6 +50,20 @@ def get_context_data(self, **kwargs):
         )
         cancel_url = self.request.build_absolute_uri(reverse("home"))
 
+        try:
+            id = djstripe_settings.get_subscriber_model().objects.first().id
+
+        except AttributeError:
+            id = (
+                djstripe_settings.get_subscriber_model()
+                .objects.create(username="sample@sample.com", email="sample@sample.com")
+                .id
+            )
+
+        # example of how to insert the SUBSCRIBER_CUSTOMER_KEY: id in the metadata
+        # to add customer.subscriber to the newly created/updated customer.
+        metadata = {f"{djstripe_settings.SUBSCRIBER_CUSTOMER_KEY}": id}
+
         # ! Note that Stripe will always create a new Customer Object if customer id not provided
         # ! even if customer_email is provided!
         session = stripe.checkout.Session.create(
@@ -75,6 +90,7 @@ def get_context_data(self, **kwargs):
             mode="payment",
             success_url=success_url,
             cancel_url=cancel_url,
+            metadata=metadata,
         )
 
         ctx["CHECKOUT_SESSION_ID"] = session.id
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
@@ -79,6 +79,7 @@
     FAKE_EVENT_CUSTOMER_SOURCE_DELETED_DUPE,
     FAKE_EVENT_CUSTOMER_SUBSCRIPTION_CREATED,
     FAKE_EVENT_CUSTOMER_SUBSCRIPTION_DELETED,
+    FAKE_EVENT_CUSTOMER_UPDATED,
     FAKE_EVENT_DISPUTE_CLOSED,
     FAKE_EVENT_DISPUTE_CREATED,
     FAKE_EVENT_DISPUTE_FUNDS_REINSTATED_FULL,
@@ -840,6 +841,44 @@ def test_checkout_session_async_payment_failed(
         session = Session.objects.get(id=fake_stripe_event["data"]["object"]["id"])
         self.assertEqual(session.customer.id, self.customer.id)
 
+    @patch(
+        "stripe.checkout.Session.retrieve", return_value=FAKE_SESSION_I, autospec=True
+    )
+    @patch("stripe.Customer.retrieve", return_value=FAKE_CUSTOMER, autospec=True)
+    @patch(
+        "stripe.PaymentIntent.retrieve",
+        return_value=FAKE_PAYMENT_INTENT_I,
+        autospec=True,
+    )
+    @patch("stripe.Event.retrieve", autospec=True)
+    def test_checkout_session_completed_customer_subscriber_added(
+        self,
+        event_retrieve_mock,
+        payment_intent_retrieve_mock,
+        customer_retrieve_mock,
+        session_retrieve_mock,
+    ):
+        # because create_for_user method adds subscriber
+        self.customer.subcriber = None
+        self.customer.save()
+
+        fake_stripe_event = deepcopy(FAKE_EVENT_SESSION_COMPLETED)
+        fake_stripe_event["data"]["object"]["metadata"] = {
+            "djstripe_subscriber": self.user.id
+        }
+        event_retrieve_mock.return_value = fake_stripe_event
+
+        event = Event.sync_from_stripe_data(fake_stripe_event)
+        event.invoke_webhook_handlers()
+
+        # refresh self.customer from db
+        self.customer.refresh_from_db()
+
+        session = Session.objects.get(id=fake_stripe_event["data"]["object"]["id"])
+        self.assertEqual(session.customer.id, self.customer.id)
+        self.assertEqual(self.customer.subscriber, self.user)
+        self.assertEqual(self.customer.metadata, {"djstripe_subscriber": self.user.id})
+
 
 class TestCustomerEvents(EventTestCase):
     def setUp(self):
@@ -865,6 +904,64 @@ def test_customer_created(self, event_retrieve_mock, customer_retrieve_mock):
             customer.currency, fake_stripe_event["data"]["object"]["currency"]
         )
 
+    @patch("stripe.Customer.retrieve", autospec=True)
+    @patch("stripe.Event.retrieve", autospec=True)
+    def test_customer_metadata_created(
+        self, event_retrieve_mock, customer_retrieve_mock
+    ):
+
+        fake_customer = deepcopy(FAKE_CUSTOMER)
+        fake_customer["metadata"] = {"djstripe_subscriber": self.user.id}
+
+        fake_stripe_event = deepcopy(FAKE_EVENT_CUSTOMER_CREATED)
+
+        fake_stripe_event["data"]["object"] = fake_customer
+
+        event_retrieve_mock.return_value = fake_stripe_event
+        customer_retrieve_mock.return_value = fake_customer
+
+        event = Event.sync_from_stripe_data(fake_stripe_event)
+        event.invoke_webhook_handlers()
+
+        customer = Customer.objects.get(id=fake_stripe_event["data"]["object"]["id"])
+        self.assertEqual(
+            customer.balance, fake_stripe_event["data"]["object"]["balance"]
+        )
+        self.assertEqual(
+            customer.currency, fake_stripe_event["data"]["object"]["currency"]
+        )
+        self.assertEqual(customer.subscriber, self.user)
+        self.assertEqual(customer.metadata, {"djstripe_subscriber": self.user.id})
+
+    @patch("stripe.Customer.retrieve", autospec=True)
+    @patch("stripe.Event.retrieve", autospec=True)
+    def test_customer_metadata_updated(
+        self, event_retrieve_mock, customer_retrieve_mock
+    ):
+
+        fake_customer = deepcopy(FAKE_CUSTOMER)
+        fake_customer["metadata"] = {"djstripe_subscriber": self.user.id}
+
+        fake_stripe_event = deepcopy(FAKE_EVENT_CUSTOMER_UPDATED)
+
+        fake_stripe_event["data"]["object"] = fake_customer
+
+        event_retrieve_mock.return_value = fake_stripe_event
+        customer_retrieve_mock.return_value = fake_customer
+
+        event = Event.sync_from_stripe_data(fake_stripe_event)
+        event.invoke_webhook_handlers()
+
+        customer = Customer.objects.get(id=fake_stripe_event["data"]["object"]["id"])
+        self.assertEqual(
+            customer.balance, fake_stripe_event["data"]["object"]["balance"]
+        )
+        self.assertEqual(
+            customer.currency, fake_stripe_event["data"]["object"]["currency"]
+        )
+        self.assertEqual(customer.subscriber, self.user)
+        self.assertEqual(customer.metadata, {"djstripe_subscriber": self.user.id})
+
     @patch(
         "stripe.Customer.retrieve_source",
         side_effect=[deepcopy(FAKE_CARD), deepcopy(FAKE_CARD_III)],