Repository: braintree/braintree-web-drop-in
Pull request #744 (2021-06-15 16:17:54)

Apple pay fix
### Summary

Prevents users from double clicking Apple Pay button (causing an invalid session message in the console).

### 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 8cb1f31..c183abf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@ CHANGELOG
 unreleased
 ----------
 - Update braintree-web to v3.78.1
+- Fix issue where Apple Pay button would not disable when session begins
 
 1.30.0
 ------
diff --git a/src/views/payment-sheet-views/apple-pay-view.js b/src/views/payment-sheet-views/apple-pay-view.js
index 548923b..63cc69b 100644
--- a/src/views/payment-sheet-views/apple-pay-view.js
+++ b/src/views/payment-sheet-views/apple-pay-view.js
@@ -27,12 +27,12 @@ ApplePayView.prototype.initialize = function () {
   delete self.applePayConfiguration.applePaySessionVersion;
 
   return btApplePay.create({client: this.client}).then(function (applePayInstance) {
-    var buttonDiv = self.getElementById('apple-pay-button');
+    self.buttonDiv = self.getElementById('apple-pay-button');
 
     self.applePayInstance = applePayInstance;
 
-    buttonDiv.onclick = self._showPaymentSheet.bind(self);
-    buttonDiv.style['-apple-pay-button-style'] = self.model.merchantConfiguration.applePay.buttonStyle || 'black';
+    self.buttonDiv.onclick = self._showPaymentSheet.bind(self);
+    self.buttonDiv.style['-apple-pay-button-style'] = self.model.merchantConfiguration.applePay.buttonStyle || 'black';
 
     self.model.asyncDependencyReady(ApplePayView.ID);
   }).catch(function (err) {
@@ -45,8 +45,16 @@ ApplePayView.prototype.initialize = function () {
 
 ApplePayView.prototype._showPaymentSheet = function () {
   var self = this;
-  var request = self.applePayInstance.createPaymentRequest(this.applePayConfiguration.paymentRequest);
-  var session = new global.ApplePaySession(self.applePaySessionVersion, request);
+  var request, session;
+
+  if (this._sessionInProgress) {
+    return false;
+  }
+
+  this._sessionInProgress = true;
+
+  request = this.applePayInstance.createPaymentRequest(this.applePayConfiguration.paymentRequest);
+  session = new global.ApplePaySession(self.applePaySessionVersion, request);
 
   session.onvalidatemerchant = function (event) {
     self.applePayInstance.performValidation({
@@ -56,6 +64,7 @@ ApplePayView.prototype._showPaymentSheet = function () {
       session.completeMerchantValidation(validationData);
     }).catch(function (validationErr) {
       self.model.reportError(validationErr);
+      self._sessionInProgress = false;
       session.abort();
     });
   };
@@ -64,11 +73,13 @@ ApplePayView.prototype._showPaymentSheet = function () {
     self.applePayInstance.tokenize({
       token: event.payment.token
     }).then(function (payload) {
+      self._sessionInProgress = false;
       session.completePayment(global.ApplePaySession.STATUS_SUCCESS);
       payload.rawPaymentData = event.payment;
       self.model.addPaymentMethod(payload);
     }).catch(function (tokenizeErr) {
       self.model.reportError(tokenizeErr);
+      self._sessionInProgress = false;
       session.completePayment(global.ApplePaySession.STATUS_FAILURE);
     });
   };


Test patch:
diff --git a/test/unit/views/payment-sheet-views/apple-pay-view.js b/test/unit/views/payment-sheet-views/apple-pay-view.js
index 15876e9..73032c6 100644
--- a/test/unit/views/payment-sheet-views/apple-pay-view.js
+++ b/test/unit/views/payment-sheet-views/apple-pay-view.js
@@ -26,6 +26,7 @@ describe('ApplePayView', () => {
       testContext.div = document.createElement('div');
 
       testContext.fakeApplePaySession = {
+        abort: jest.fn(),
         begin: jest.fn(),
         completeMerchantValidation: jest.fn(),
         completePayment: jest.fn()
@@ -160,20 +161,20 @@ describe('ApplePayView', () => {
     });
 
     describe('button click handler', () => {
+      let button;
+
       beforeEach(() => {
         testContext.view = new ApplePayView(testContext.applePayViewOptions);
 
         return testContext.view.initialize().then(() => {
-          const button = document.querySelector('[data-braintree-id="apple-pay-button"]');
-
-          testContext.buttonClickHandler = button.onclick;
+          button = document.querySelector('[data-braintree-id="apple-pay-button"]');
         });
       });
 
       test('creates an ApplePaySession with the payment request', () => {
         testContext.view.applePayInstance.createPaymentRequest = jest.fn().mockReturnValue(testContext.fakePaymentRequest);
 
-        testContext.buttonClickHandler();
+        button.onclick();
 
         expect(testContext.view.applePayInstance.createPaymentRequest).toBeCalledWith(testContext.fakePaymentRequest);
         expect(global.ApplePaySession).toBeCalledWith(2, testContext.fakePaymentRequest);
@@ -183,7 +184,7 @@ describe('ApplePayView', () => {
         testContext.view.applePaySessionVersion = 3;
         testContext.view.applePayInstance.createPaymentRequest = jest.fn().mockReturnValue(testContext.fakePaymentRequest);
 
-        testContext.buttonClickHandler();
+        button.onclick();
 
         expect(testContext.view.applePayInstance.createPaymentRequest).toBeCalledWith(testContext.fakePaymentRequest);
         expect(global.ApplePaySession).toBeCalledWith(3, testContext.fakePaymentRequest);
@@ -192,7 +193,23 @@ describe('ApplePayView', () => {
       test('begins the ApplePaySession', () => {
         testContext.view.applePayInstance.createPaymentRequest = jest.fn().mockReturnValue(testContext.fakePaymentRequest);
 
-        testContext.buttonClickHandler();
+        button.onclick();
+
+        expect(testContext.fakeApplePaySession.begin).toBeCalledTimes(1);
+      });
+
+      test('only creates one ApplePaySession at a time', async () => {
+        testContext.view.applePayInstance.createPaymentRequest = jest.fn().mockReturnValue(testContext.fakePaymentRequest);
+
+        button.onclick();
+        button.onclick();
+        button.onclick();
+        button.onclick();
+        button.onclick();
+        button.onclick();
+        button.onclick();
+
+        await new Promise((resolve) => setTimeout(resolve, 1));
 
         expect(testContext.fakeApplePaySession.begin).toBeCalledTimes(1);
       });
@@ -201,7 +218,7 @@ describe('ApplePayView', () => {
         test('performs merchant validation', () => {
           const stubEvent = { validationURL: 'fake' };
 
-          testContext.buttonClickHandler();
+          button.onclick();
           testContext.fakeApplePaySession.onvalidatemerchant(stubEvent);
 
           expect(testContext.view.applePayInstance.performValidation).toBeCalledWith({
@@ -221,7 +238,7 @@ describe('ApplePayView', () => {
               done();
             };
 
-            testContext.buttonClickHandler();
+            button.onclick();
             testContext.fakeApplePaySession.onvalidatemerchant({ validationURL: 'fake' });
           }
         );
@@ -238,10 +255,32 @@ describe('ApplePayView', () => {
               done();
             };
 
-            testContext.buttonClickHandler();
+            button.onclick();
             testContext.fakeApplePaySession.onvalidatemerchant({ validationURL: 'fake' });
           }
         );
+
+        test(
+          'can start a new session after validation fails',
+          async () => {
+            const fakeError = new Error('fail.');
+
+            jest.spyOn(testContext.view.model, 'reportError').mockImplementation();
+            testContext.fakeApplePayInstance.performValidation.mockRejectedValue(fakeError);
+
+            button.onclick();
+
+            testContext.fakeApplePaySession.onvalidatemerchant({ validationURL: 'fake' });
+
+            expect(testContext.fakeApplePaySession.begin).toBeCalledTimes(1);
+
+            await new Promise((resolve) => setTimeout(resolve, 1));
+
+            button.onclick();
+
+            expect(testContext.fakeApplePaySession.begin).toBeCalledTimes(2);
+          }
+        );
       });
 
       describe('session.onpaymentauthorized', () => {
@@ -250,7 +289,7 @@ describe('ApplePayView', () => {
             payment: { token: 'foo' }
           };
 
-          testContext.buttonClickHandler();
+          button.onclick();
           testContext.fakeApplePaySession.onpaymentauthorized(stubEvent);
 
           expect(testContext.fakeApplePayInstance.tokenize).toBeCalledWith({ token: 'foo' });
@@ -264,12 +303,10 @@ describe('ApplePayView', () => {
               testContext.fakeApplePaySession.completePayment = status => {
                 expect(status).toBe(global.ApplePaySession.STATUS_SUCCESS);
 
-                setTimeout(() => {
-                  done();
-                }, 200);
+                done();
               };
 
-              testContext.buttonClickHandler();
+              button.onclick();
               testContext.fakeApplePaySession.onpaymentauthorized({
                 payment: { token: 'foo' }
               });
@@ -289,12 +326,32 @@ describe('ApplePayView', () => {
               done();
             };
 
-            testContext.buttonClickHandler();
+            button.onclick();
             testContext.fakeApplePaySession.onpaymentauthorized({
               payment: { token: 'foo' }
             });
           });
 
+          test(
+            'can start a new session after success',
+            async () => {
+              testContext.fakeApplePayInstance.tokenize.mockResolvedValue({ foo: 'bar' });
+
+              button.onclick();
+
+              testContext.fakeApplePaySession.onpaymentauthorized({
+                payment: { token: 'foo' }
+              });
+
+              expect(testContext.fakeApplePaySession.begin).toBeCalledTimes(1);
+
+              await new Promise((resolve) => setTimeout(resolve, 1));
+
+              button.onclick();
+              expect(testContext.fakeApplePaySession.begin).toBeCalledTimes(2);
+            }
+          );
+
           test(
             'provides shipping and billing contact in payment method when present in ApplePayPayment',
             done => {
@@ -311,7 +368,7 @@ describe('ApplePayView', () => {
                 done();
               };
 
-              testContext.buttonClickHandler();
+              button.onclick();
               testContext.fakeApplePaySession.onpaymentauthorized({
                 payment: {
                   token: 'foo',
@@ -338,12 +395,35 @@ describe('ApplePayView', () => {
                 done();
               };
 
-              testContext.buttonClickHandler();
+              button.onclick();
               testContext.fakeApplePaySession.onpaymentauthorized({
                 payment: { token: 'foo' }
               });
             }
           );
+
+          test(
+            'can start a new session after a tokenization error',
+            async () => {
+              const fakeError = new Error('fail.');
+
+              jest.spyOn(testContext.view.model, 'reportError').mockImplementation();
+              testContext.fakeApplePayInstance.tokenize.mockRejectedValue(fakeError);
+
+              button.onclick();
+
+              testContext.fakeApplePaySession.onpaymentauthorized({
+                payment: { token: 'foo' }
+              });
+
+              expect(testContext.fakeApplePaySession.begin).toBeCalledTimes(1);
+
+              await new Promise((resolve) => setTimeout(resolve, 1));
+
+              button.onclick();
+              expect(testContext.fakeApplePaySession.begin).toBeCalledTimes(2);
+            }
+          );
         });
       });
     });