Repository: braintree/braintree-web-drop-in (js)

Issue:
No UI feedback presented for 3DS BraintreeError
### General information

* SDK/Library version: 1.25.0
* Environment: Sandbox and Production
* Browser and OS: Chrome on Windows 10

### Issue description

The DropIn doesn't show any UI feedback when `requestPaymentMethod()` errors due to a 3DS issue.

#### To Replicate
* Go To https://codepen.io/braintree/pen/KjWqGx
* Use the "Error on Authentication" 3DS test card `4000000000001125`, `01/2024`
* Click "Pay Now"
* You can see the error showing in the console but there is no feedback to the user that anything has gone wrong
![image](https://user-images.githubusercontent.com/13466620/107638891-15054e80-6c68-11eb-9bd6-59da6b93e8af.png)

Compare that to the feedback given for things like invalid card number:
![image](https://user-images.githubusercontent.com/13466620/107637983-e0dd5e00-6c66-11eb-80ea-8b129f4eb42d.png)

*** 

As a 2nd point to this; if we are supposed to handle the UI of these errors ourselves, how are we supposed to differentiate between the errors which the DropIn has already presented feedback for and the ones which it has not?

Currently I am doing `if (error.name === 'DropinError' && error.message === 'No payment method is available.')` to detect the handled ones but that feels very fragile.

Pull request description:
fix: correct handling for a failed 3ds authentication
closes #694

### Summary

Basically, when card tokenization happens, we add the nonce payload to the Drop-in as requestable payment method. When 3ds succeeds, we update that payload with the new nonce and liability shift info. When it fails, we don't update the data, but the nonce is still left behind as a requestable payment method, even though it has been consumed.

This PR brings the behavior up to date with the iOS and Android SDKs where if the 3ds auth fails, it clears out the consumed credit card and refreshes any vaulted payment methods.

In the future, I think it would be wise to add a feature to allow the failed 3ds auth to redirect back to the card form (if the initiating payment method was from the card form), but that will require a little more design work and a few more weeks for localization on a new error message.

This current implementation will get merchants unstuck right now.

### Checklist

- [x] Added a changelog entry

### Authors
> List GitHub usernames for everyone who contributed to this pull request.

- @crookedneighbor 
- @jplukarski 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ed5736..fd46ad9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@ unreleased
 ----------
 - Update braintree-web to v3.76.4
 - Add `threeDSecure.cardinalSDKConfig` option to `dropin.create`
+- Fix issue where Drop-in would not clear out a tokenized credit card after 3D Secure authentication fails (#694)
 
 1.28.0
 ------
diff --git a/src/dropin.js b/src/dropin.js
index 1e2635d..f2cb6f0 100644
--- a/src/dropin.js
+++ b/src/dropin.js
@@ -893,9 +893,16 @@ Dropin.prototype.requestPaymentMethod = function (options) {
 
         return payload;
       }).catch(function (err) {
-        self._mainView.hideLoadingIndicator();
+        self.clearSelectedPaymentMethod();
+
+        return self._model.refreshPaymentMethods().then(function () {
+          self._mainView.hideLoadingIndicator();
 
-        return Promise.reject(err);
+          return Promise.reject(new DropinError({
+            message: 'Something went wrong during 3D Secure authentication. Please try again.',
+            braintreeWebError: err
+          }));
+        });
       });
     }
 


Test patch:
diff --git a/test/unit/dropin.js b/test/unit/dropin.js
index 556e2df..79d91ed 100644
--- a/test/unit/dropin.js
+++ b/test/unit/dropin.js
@@ -1313,6 +1313,81 @@ describe('Dropin', () => {
         });
       });
     });
+
+    test('rejects when 3D Secure authentication fails', (done) => {
+      const fakePayload = {
+        nonce: 'cool-nonce',
+        type: 'CreditCard'
+      };
+
+      testContext.dropinOptions.merchantConfiguration.threeDSecure = {};
+
+      const instance = new Dropin(testContext.dropinOptions);
+
+      instance._initialize(() => {
+        jest.spyOn(instance._mainView, 'requestPaymentMethod').mockResolvedValue(fakePayload);
+        instance._threeDSecure = {
+          verify: jest.fn().mockRejectedValue(new Error('some 3ds error'))
+        };
+
+        instance.requestPaymentMethod((err) => {
+          expect(err.message).toBe('Something went wrong during 3D Secure authentication. Please try again.');
+          expect(err._braintreeWebError.message).toBe('some 3ds error');
+
+          done();
+        });
+      });
+    });
+
+    test('removes consumed credit card payment method when 3D Secure authentication fails', (done) => {
+      const fakePayload = {
+        nonce: 'cool-nonce',
+        type: 'CreditCard'
+      };
+
+      testContext.dropinOptions.merchantConfiguration.threeDSecure = {};
+
+      const instance = new Dropin(testContext.dropinOptions);
+
+      instance._initialize(() => {
+        jest.spyOn(instance, 'clearSelectedPaymentMethod').mockImplementation();
+        jest.spyOn(instance._mainView, 'requestPaymentMethod').mockResolvedValue(fakePayload);
+        instance._threeDSecure = {
+          verify: jest.fn().mockRejectedValue(new Error('some 3ds error'))
+        };
+
+        instance.requestPaymentMethod(() => {
+          expect(instance.clearSelectedPaymentMethod).toBeCalledTimes(1);
+
+          done();
+        });
+      });
+    });
+
+    test('refreshes vaulted payment methods when 3D Secure authentication fails', (done) => {
+      const fakePayload = {
+        nonce: 'cool-nonce',
+        type: 'CreditCard'
+      };
+
+      testContext.dropinOptions.merchantConfiguration.threeDSecure = {};
+
+      const instance = new Dropin(testContext.dropinOptions);
+
+      instance._initialize(() => {
+        jest.spyOn(instance._model, 'refreshPaymentMethods').mockResolvedValue();
+        jest.spyOn(instance._mainView, 'requestPaymentMethod').mockResolvedValue(fakePayload);
+        instance._threeDSecure = {
+          verify: jest.fn().mockRejectedValue(new Error('some 3ds error'))
+        };
+
+        instance.requestPaymentMethod(() => {
+          expect(instance._model.refreshPaymentMethods).toBeCalledTimes(1);
+
+          done();
+        });
+      });
+    });
   });
 
   describe('isPaymentMethodRequestable', () => {