Repository: braintree/braintree-web-drop-in
Pull request #917 (2023-12-18 20:45:05)

Emit paymentMethodRequestable event after 3DS Challenge is completed
### Summary

[Jira](https://paypal.atlassian.net/browse/LI-29388)

Holds off sending the [`paymentMethodRequestable`](https://braintree.github.io/braintree-web-drop-in/docs/current/Dropin.html#event:paymentMethodRequestable) event until after `verifyCard` has completed for 3DS flows.

Addresses #805 

Testing strategy:

Run the [Drop-in demo in development mode](https://github.com/braintree/braintree-web-drop-in/blob/main/DEVELOPMENT.md#demo-app-and-jsdocs) and add the following query parameters to the URL in your browser:

`?threeDSecure=true&authorization=<generate-a-client-token>`

Then test [each test card listed in our 3DS documentation](https://developer.paypal.com/braintree/docs/guides/3d-secure/testing-go-live/ruby) to make sure everything works as expected on each flow. Open the dev console and notice that the event does not fire off until 3DS authentication is completed:

`paymentMethodRequestable {paymentMethodIsSelected: true, type: 'CreditCard'}`

### Checklist

- [X] Added a changelog entry

### Authors
> List GitHub usernames for everyone who contributed to this pull request.

- @jplukarski 



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e9ea3f2..5c6587f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,10 @@
 # CHANGELOG
 
 ## UNRELEASED
-  - Apple Pay: add error message prompting the customer to click the Apple Pay button when `requestPaymentMethod` is called.
+  - Apple Pay
+    - add error message prompting the customer to click the Apple Pay button when `requestPaymentMethod` is called.
+  - 3D Secure
+    - Fix issue where `paymentMethodRequestable` event would fire before 3DS challenge has been completed. (closes [#805](https://github.com/braintree/braintree-web-drop-in/issues/805))
 
 ## 1.41.0
   - Update braintree-web to 3.97.4
diff --git a/src/dropin-model.js b/src/dropin-model.js
index ef063cf..5b923ce 100644
--- a/src/dropin-model.js
+++ b/src/dropin-model.js
@@ -52,6 +52,7 @@ function DropinModel(options) {
   this.failedDependencies = {};
   this._options = options;
   this._setupComplete = false;
+  this.shouldWaitForVerifyCard = false;
 
   while (this.rootNode.parentNode) {
     this.rootNode = this.rootNode.parentNode;
@@ -223,6 +224,10 @@ DropinModel.prototype._shouldEmitRequestableEvent = function (options) {
     return false;
   }
 
+  if (this.shouldWaitForVerifyCard) {
+    return false;
+  }
+
   if (requestableStateHasNotChanged && (!options.isRequestable || nonceHasNotChanged)) {
     return false;
   }
diff --git a/src/dropin.js b/src/dropin.js
index 6c02473..b5991b0 100644
--- a/src/dropin.js
+++ b/src/dropin.js
@@ -876,10 +876,16 @@ Dropin.prototype.requestPaymentMethod = function (options) {
       self._mainView.showLoadingIndicator();
 
       return self._threeDSecure.verify(payload, options.threeDSecure).then(function (newPayload) {
+        self._model.shouldWaitForVerifyCard = false;
         payload.nonce = newPayload.nonce;
         payload.liabilityShifted = newPayload.liabilityShifted;
         payload.liabilityShiftPossible = newPayload.liabilityShiftPossible;
         payload.threeDSecureInfo = newPayload.threeDSecureInfo;
+        self._model.setPaymentMethodRequestable({
+          isRequestable: Boolean(newPayload),
+          type: newPayload.type,
+          selectedPaymentMethod: payload
+        });
 
         self._mainView.hideLoadingIndicator();
 
diff --git a/src/lib/three-d-secure.js b/src/lib/three-d-secure.js
index d8d7494..38fc6cd 100644
--- a/src/lib/three-d-secure.js
+++ b/src/lib/three-d-secure.js
@@ -54,6 +54,7 @@ ThreeDSecure.prototype.verify = function (payload, merchantProvidedData) {
 
   verifyOptions.additionalInformation = verifyOptions.additionalInformation || {};
   verifyOptions.additionalInformation.acsWindowSize = verifyOptions.additionalInformation.acsWindowSize || DEFAULT_ACS_WINDOW_SIZE;
+  this._model.shouldWaitForVerifyCard = true;
 
   return this._instance.verifyCard(verifyOptions);
 };


Test patch:
diff --git a/test/unit/dropin-model.js b/test/unit/dropin-model.js
index 486ac93..ca83e52 100644
--- a/test/unit/dropin-model.js
+++ b/test/unit/dropin-model.js
@@ -1084,6 +1084,31 @@ describe('DropinModel', () => {
       }
     );
 
+    test(
+      'does not emit paymentMethodRequestable event until after three D secure verification has been completed',
+      () => {
+        testContext.model.shouldWaitForVerifyCard = true;
+        testContext.model.setPaymentMethodRequestable({
+          isRequestable: true,
+          type: 'card'
+        });
+
+        expect(testContext.model._emit).not.toBeCalled();
+
+        testContext.model.shouldWaitForVerifyCard = false;
+
+        testContext.model.setPaymentMethodRequestable({
+          isRequestable: true,
+          type: 'card',
+          selectedPaymentMethod: {
+            nonce: 'fake-nonce'
+          }
+        });
+
+        expect(testContext.model._emit).toBeCalled();
+      }
+    );
+
     test(
       'sets isPaymentMethodRequestable to false when isRequestable is false',
       () => {
diff --git a/test/unit/dropin.js b/test/unit/dropin.js
index f464326..e1694aa 100644
--- a/test/unit/dropin.js
+++ b/test/unit/dropin.js
@@ -1212,6 +1212,43 @@ describe('Dropin', () => {
       }
     );
 
+    test('sets shouldWaitForVerifyCard to false and calls setPaymentMethodRequestable when 3D secure is complete', done => {
+      let instance;
+      const fakePayload = {
+        nonce: 'cool-nonce',
+        type: 'CreditCard'
+      };
+      const fakeNewPayload = {
+        nonce: 'new-nonce',
+        liabilityShifted: true,
+        liabilityShiftPossible: true,
+        type: fakePayload.type
+      };
+
+      testContext.dropinOptions.merchantConfiguration.threeDSecure = {};
+
+      instance = new Dropin(testContext.dropinOptions);
+
+      instance._initialize(() => {
+        jest.spyOn(instance._mainView, 'requestPaymentMethod').mockResolvedValue(fakePayload);
+        jest.spyOn(instance._model, 'setPaymentMethodRequestable').mockResolvedValue();
+        instance._threeDSecure = {
+          verify: jest.fn().mockResolvedValue(fakeNewPayload)
+        };
+
+        instance.requestPaymentMethod(() => {
+          expect(instance._model.shouldWaitForVerifyCard).toBe(false);
+          expect(instance._model.setPaymentMethodRequestable).toBeCalledWith({
+            isRequestable: true,
+            type: fakeNewPayload.type,
+            selectedPaymentMethod: fakeNewPayload
+          });
+
+          done();
+        });
+      });
+    });
+
     test(
       'does not call 3D Secure if network tokenized google pay',
       done => {
diff --git a/test/unit/lib/three-d-secure.js b/test/unit/lib/three-d-secure.js
index d82bb7c..8b3444e 100644
--- a/test/unit/lib/three-d-secure.js
+++ b/test/unit/lib/three-d-secure.js
@@ -119,6 +119,19 @@ describe('ThreeDSecure', () => {
       });
     });
 
+    test('sets shouldWaitForVerifyCard to true', () => {
+      expect(testContext.model.shouldWaitForVerifyCard).toBe(false);
+
+      return testContext.tds.verify({
+        nonce: 'old-nonce',
+        details: {
+          bin: '123456'
+        }
+      }).then(() => {
+        expect(testContext.model.shouldWaitForVerifyCard).toBe(true);
+      });
+    });
+
     test('rejects if verifyCard rejects', () => {
       testContext.threeDSecureInstance.verifyCard.mockRejectedValue({
         message: 'A message'