Repository: webkom/lego

Issue:
Stripe paymentIntent is not recreated on re-registration
Reproduction steps
- Register for a priced event
- Make sure the payment intent is created. F.ex. by using a card that will be declined
- Unregister from the event
- Re-register, either manually if possible or admin-register
- Try to pay.

### The issue:
The payment intent is `canceled` when unregistering, but the same registration is used when registering again. We only check whether the paymentIntent exists, not whether it is valid. This means that we don't change out the payment intent and we are stuck with a payment intent with a state of `canceled`.

### Proposed solution:
Check the state of the payment intent when creating a payment intent. If it is **not** `requires_payment_method`, we should create a new payment intent.

This check should be used anywhere where we use the `async_create_payment_intent` task or check whether the payment intent exists.


Discussion:

Resolving patch:
diff --git a/lego/apps/events/constants.py b/lego/apps/events/constants.py
--- a/lego/apps/events/constants.py
+++ b/lego/apps/events/constants.py
@@ -85,6 +85,7 @@
 PAYMENT_SUCCESS = "succeeded"
 PAYMENT_FAILURE = "failed"
 PAYMENT_MANUAL = "manual"
+PAYMENT_CANCELED = "canceled"
 
 PAYMENT_STATUS_CHOICES = (
     (PAYMENT_MANUAL, PAYMENT_MANUAL),
@@ -95,6 +96,7 @@
 
 STRIPE_EVENT_INTENT_SUCCESS = "payment_intent.succeeded"
 STRIPE_EVENT_INTENT_PAYMENT_FAILED = "payment_intent.payment_failed"
+STRIPE_EVENT_INTENT_PAYMENT_CANCELED = "payment_intent.canceled"
 STRIPE_EVENT_CHARGE_REFUNDED = "charge.refunded"
 
 # See https://stripe.com/docs/api/payment_intents/object#payment_intent_object-status
diff --git a/lego/apps/events/tasks.py b/lego/apps/events/tasks.py
--- a/lego/apps/events/tasks.py
+++ b/lego/apps/events/tasks.py
@@ -205,6 +205,20 @@ def async_retrieve_payment(self, registration_id, logger_context=None):
         )
         return
 
+    # If the payment is canceled in stripe and the webhook for some reason
+    # did not go through, we update the registration to match this, and then
+    # initiate a new payment.
+    if payment_intent["status"] == constants.STRIPE_INTENT_CANCELED:
+        self.registration.payment_status = constants.PAYMENT_CANCELED
+        self.registration.payment_intent_id = None
+        self.registration.payment_idempotency_key = None
+        self.registration.save()
+        chain(
+            async_initiate_payment.s(self.registration.id),
+            save_and_notify_payment.s(self.registration.id),
+        ).delay()
+        return
+
     notify_user_payment_initiated(
         constants.SOCKET_INITIATE_PAYMENT_SUCCESS,
         self.registration,
@@ -351,6 +365,7 @@ def stripe_webhook_event(self, event_id, event_type, logger_context=None):
     if event_type in [
         constants.STRIPE_EVENT_INTENT_SUCCESS,
         constants.STRIPE_EVENT_INTENT_PAYMENT_FAILED,
+        constants.STRIPE_EVENT_INTENT_PAYMENT_CANCELED,
     ]:
 
         serializer = StripePaymentIntentSerializer(data=event.data["object"])
@@ -380,6 +395,11 @@ def stripe_webhook_event(self, event_id, event_type, logger_context=None):
                 registration,
                 error_message="Betaling feilet",
             )
+        elif event_type == constants.STRIPE_EVENT_INTENT_PAYMENT_CANCELED:
+            registration.payment_status = constants.PAYMENT_CANCELED
+            registration.payment_intent_id = None
+            registration.idempotency_key = None
+
         registration.save()
 
     elif event_type in [constants.STRIPE_EVENT_CHARGE_REFUNDED]:


Test patch:
diff --git a/lego/apps/events/tests/test_events_api.py b/lego/apps/events/tests/test_events_api.py
--- a/lego/apps/events/tests/test_events_api.py
+++ b/lego/apps/events/tests/test_events_api.py
@@ -1525,7 +1525,8 @@ def test_unregister_with_payment(self):
         self.client.delete(f"{_get_registrations_list_url(self.event.id)}{reg.id}/")
 
         self.assertEqual(
-            stripe.PaymentIntent.retrieve(reg.payment_intent_id)["status"], "canceled"
+            stripe.PaymentIntent.retrieve(reg.payment_intent_id)["status"],
+            constants.STRIPE_INTENT_CANCELED,
         )
 
     def test_admin_unregister_with_payment(self):
@@ -1550,7 +1551,61 @@ def test_admin_unregister_with_payment(self):
         reg.refresh_from_db()
 
         self.assertEqual(
-            stripe.PaymentIntent.retrieve(reg.payment_intent_id)["status"], "canceled"
+            stripe.PaymentIntent.retrieve(reg.payment_intent_id)["status"],
+            constants.STRIPE_INTENT_CANCELED,
+        )
+
+        stripe_event = stripe.Event.list(limit=1)["data"][0]
+        stripe_webhook_event(
+            event_id=stripe_event["id"], event_type=stripe_event["type"]
+        )
+
+        reg.refresh_from_db()
+        self.assertEqual(reg.payment_status, constants.PAYMENT_CANCELED)
+        self.assertIsNone(reg.payment_intent_id)
+
+    @mock.patch("lego.apps.events.tasks.notify_user_payment_initiated")
+    def test_payment_is_possible_on_re_registration(self, mock_notify):
+        """
+        The user should be able to pay, even if the webhook from stripe on payment cancellation is
+        unsuccessful.
+        - User registers
+        - Payment is initiated, but not completed
+        - User unregisters
+        - User re-registers
+        """
+
+        reg = Registration.objects.get_or_create(
+            event=self.event, user=self.abakus_user_2
+        )[0]
+        self.event.register(reg)
+        self.event.save()
+
+        self.client.force_authenticate(self.abakus_user_2)
+        self.get_payment_intent()
+
+        self.client.delete(f"{_get_registrations_list_url(self.event.id)}{reg.id}/")
+        reg.refresh_from_db()
+        self.assertEqual(
+            stripe.PaymentIntent.retrieve(reg.payment_intent_id)["status"],
+            constants.STRIPE_INTENT_CANCELED,
+        )
+
+        self.event.register(reg)
+        self.get_payment_intent()
+
+        reg.refresh_from_db()
+        stripe_intent = stripe.PaymentIntent.retrieve(reg.payment_intent_id)
+        self.assertNotEqual(
+            stripe_intent["status"],
+            constants.STRIPE_INTENT_CANCELED,
+        )
+
+        mock_notify.assert_called_with(
+            constants.SOCKET_INITIATE_PAYMENT_SUCCESS,
+            reg,
+            success_message="Betaling påbegynt",
+            client_secret=stripe_intent["client_secret"],
         )
 
     def test_cancel_on_payment_manual(self):