diff --git a/lib/recurly/google-pay/google-pay.braintree.js b/lib/recurly/google-pay/google-pay.braintree.js new file mode 100644 index 00000000..69677f41 --- /dev/null +++ b/lib/recurly/google-pay/google-pay.braintree.js @@ -0,0 +1,64 @@ +import Promise from 'promise'; +import { GooglePay } from './google-pay'; +import Debug from 'debug'; +import BraintreeLoader from '../../util/braintree-loader'; +import errors from '../errors'; +import { payWithGoogle } from './pay-with-google'; + +const debug = Debug('recurly:google-pay:braintree'); + +export class GooglePayBraintree extends GooglePay { + configure (options) { + debug('Initializing client'); + + const authorization = options.braintree.clientAuthorization; + + BraintreeLoader.loadModules('googlePayment', 'dataCollector') + .then(() => window.braintree.client.create({ authorization })) + .then(client => Promise.all([ + window.braintree.dataCollector.create({ client }), + window.braintree.googlePayment.create({ + client, + googlePayVersion: 2, + googleMerchantId: options.googleMerchantId, + }) + ])) + .then(([dataCollector, googlePayment]) => { this.braintree = { dataCollector, googlePayment }; }) + .catch(err => this.emit('error', errors('google-pay-init-error', { err }))) + .then(() => super.configure(options)); + } + + createButton ({ paymentOptions, isReadyToPayRequest, paymentDataRequest: recurlyPaymentDataRequest, buttonOptions }) { + // allow Braintree to set the tokenizationSpecification for its gateway + recurlyPaymentDataRequest.allowedPaymentMethods.forEach(method => { + if (method.tokenizationSpecification) delete method.tokenizationSpecification; + }); + const paymentDataRequest = this.braintree.googlePayment.createPaymentDataRequest(recurlyPaymentDataRequest); + debug('Creating button', recurlyPaymentDataRequest, paymentDataRequest); + return payWithGoogle({ + paymentOptions, + isReadyToPayRequest, + paymentDataRequest, + buttonOptions, + }); + } + + token (paymentData) { + debug('Creating token', paymentData); + + return this.braintree.googlePayment + .parseResponse(paymentData) + .then(token => { + token.deviceData = this.braintree.dataCollector.deviceData; + paymentData.paymentMethodData.gatewayToken = token; + return super.token(paymentData); + }); + } + + mapPaymentData (paymentData) { + return { + type: 'braintree', + ...super.mapPaymentData(paymentData), + }; + } +} diff --git a/lib/recurly/google-pay/index.js b/lib/recurly/google-pay/index.js index 66af41d9..88653241 100644 --- a/lib/recurly/google-pay/index.js +++ b/lib/recurly/google-pay/index.js @@ -1,4 +1,5 @@ import { GooglePay } from './google-pay'; +import { GooglePayBraintree } from './google-pay.braintree'; /** * Returns a GooglePay instance. @@ -7,7 +8,9 @@ import { GooglePay } from './google-pay'; * @return {GooglePay} */ export function factory (options) { - const factoryClass = GooglePay; + const factoryClass = options?.braintree?.clientAuthorization + ? GooglePayBraintree + : GooglePay; return new factoryClass(Object.assign({}, options, { recurly: this })); } diff --git a/package-lock.json b/package-lock.json index a80444b0..87771b8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "@wdio/mocha-framework": "^8.8.6", "@wdio/spec-reporter": "^8.8.6", "ajv": "^6.12.6", - "assert": "^2.0.0", + "assert": "^2.1.0", "babel-eslint": "^10.0.3", "babel-loader": "^9.1.2", "babel-plugin-istanbul": "^6.0.0", @@ -5511,24 +5511,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/aria-query/node_modules/object.assign": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", - "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -5567,15 +5549,16 @@ } }, "node_modules/assert": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/assert/-/assert-2.0.0.tgz", - "integrity": "sha512-se5Cd+js9dXJnu6Ag2JFc00t+HmHOen+8Q+L7O9zI0PqQXr20uk2J0XQqMxZEeo5U50o8Nvmmx7dZrl+Ufr35A==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", "dev": true, "dependencies": { - "es6-object-assign": "^1.1.0", - "is-nan": "^1.2.1", - "object-is": "^1.0.1", - "util": "^0.12.0" + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" } }, "node_modules/assert-plus": { @@ -6391,12 +6374,18 @@ } }, "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8541,6 +8530,22 @@ "node": ">=10" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -8551,11 +8556,12 @@ } }, "node_modules/define-properties": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz", - "integrity": "sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, "dependencies": { + "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" }, @@ -9304,6 +9310,25 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-get-iterator": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", @@ -9337,12 +9362,6 @@ "dev": true, "optional": true }, - "node_modules/es6-object-assign": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es6-object-assign/-/es6-object-assign-1.1.0.tgz", - "integrity": "sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw==", - "dev": true - }, "node_modules/es6-promise": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", @@ -10707,9 +10726,12 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/functional-red-black-tree": { "version": "1.0.1", @@ -11021,13 +11043,18 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", - "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.3" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -11305,7 +11332,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, "dependencies": { "get-intrinsic": "^1.1.3" }, @@ -11395,6 +11421,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, "dependencies": { "function-bind": "^1.1.1" }, @@ -11441,12 +11468,22 @@ } }, "node_modules/has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", - "dev": true, + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dependencies": { - "get-intrinsic": "^1.1.1" + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -11483,6 +11520,17 @@ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", "dev": true }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -15605,6 +15653,24 @@ "node": ">= 0.4" } }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", @@ -17448,6 +17514,22 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "dev": true }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/package.json b/package.json index ed943e7a..09bb3035 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@wdio/mocha-framework": "^8.8.6", "@wdio/spec-reporter": "^8.8.6", "ajv": "^6.12.6", - "assert": "^2.0.0", + "assert": "^2.1.0", "babel-eslint": "^10.0.3", "babel-loader": "^9.1.2", "babel-plugin-istanbul": "^6.0.0", diff --git a/test/unit/google-pay/google-pay.test.js b/test/unit/google-pay/google-pay.test.js index 794bd8c9..d611fd30 100644 --- a/test/unit/google-pay/google-pay.test.js +++ b/test/unit/google-pay/google-pay.test.js @@ -1,9 +1,35 @@ - import assert from 'assert'; import recurlyError from '../../../lib/recurly/errors'; +import BraintreeLoader from '../../../lib/util/braintree-loader'; import { initRecurly, nextTick, assertDone, stubGooglePaymentAPI } from '../support/helpers'; import dom from '../../../lib/util/dom'; +const INTEGRATION = { + DIRECT: 'Direct Integration', + BRAINTREE: 'Braintree Integration', +}; + +const getBraintreeStub = () => ({ + client: { + VERSION: '3.101.0', + create: sinon.stub().resolves('CLIENT'), + }, + dataCollector: { + create: sinon.stub().resolves({ deviceData: 'DEVICE_DATA' }), + }, + googlePayment: { + create: sinon.stub().resolves({ + createPaymentDataRequest: sinon.stub().callsFake((paymentRequest) => { + paymentRequest.allowedPaymentMethods.forEach(method => { + method.tokenizationSpecification = { type: 'PAYMENT_GATEWAY', parameters: { gateway: 'braintree' } }; + }); + return paymentRequest; + }), + parseResponse: sinon.stub().resolves({ nonce: 'GATEWAY_TOKEN' }), + }), + }, +}); + describe('Google Pay', function () { beforeEach(function () { this.sandbox = sinon.createSandbox(); @@ -60,688 +86,711 @@ describe('Google Pay', function () { } }); - it('requests to Recurly the merchant Google Pay info with the initial options provided', function (done) { - this.stubRequestAndGoogleApi(); - this.recurly.GooglePay(this.googlePayOpts); - - nextTick(() => assertDone(done, () => { - assert.equal(this.recurly.request.get.called, true); - assert.deepEqual(this.recurly.request.get.getCall(0).args[0], { - route: '/google_pay/info', - data: { - country: 'US', - currency: 'USD', - gateway_code : 'CODE_123', - }, - }); - })); - }); - - context('when missing a required option', function () { - const requiredKeys = ['country', 'currency']; - requiredKeys.forEach(key => { - describe(`:${key}`, function () { - beforeEach(function () { - this.googlePayOpts[key] = undefined; - this.stubRequestAndGoogleApi(); - }); - - it('emits a google-pay-config-missing error', function (done) { - const result = this.recurly.GooglePay(this.googlePayOpts); - - result.on('error', (err) => assertDone(done, () => { - assert.ok(err); - assert.equal(err.code, 'google-pay-config-missing'); - assert.equal(err.message, `Missing Google Pay configuration option: '${key}'`); - })); - }); - - it('do not initiate the pay-with-google nor requests to Recurly the merchant Google Pay info', function (done) { - this.recurly.GooglePay(this.googlePayOpts); - - nextTick(() => assertDone(done, () => { - assert.equal(this.recurly.request.get.called, false); - assert.equal(window.google.payments.api.PaymentsClient.called, false); - })); - }); - - it('do not emit any token nor the on ready event', function (done) { - const result = this.recurly.GooglePay(this.googlePayOpts); + googlePayTest(INTEGRATION.DIRECT); + googlePayTest(INTEGRATION.BRAINTREE); +}); - result.on('ready', () => done(new Error('expected to not emit a ready event'))); - result.on('token', () => done(new Error('expected to not emit a token event'))); - nextTick(done); - }); - }); - }); - }); +function googlePayTest (integrationType) { + const isDirectIntegration = integrationType === INTEGRATION.DIRECT; + const isBraintreeIntegration = integrationType === INTEGRATION.BRAINTREE; - context('when fails requesting to Recurly the merchant Google Pay info', function () { + describe(`Recurly.GooglePay ${integrationType}`, function () { beforeEach(function () { - this.stubRequestOpts.info = Promise.reject(recurlyError('api-error')); - this.stubRequestAndGoogleApi(); - }); - - it('emits an api-error', function (done) { - const result = this.recurly.GooglePay(this.googlePayOpts); - - result.on('error', (err) => assertDone(done, () => { - assert.ok(err); - assert.equal(err.code, 'api-error'); - assert.equal(err.message, 'There was an error with your request.'); - })); + if (isBraintreeIntegration) { + this.googlePayOpts.braintree = { clientAuthorization: 'valid' }; + window.braintree = getBraintreeStub(); + } }); - it('do not initiate the pay-with-google', function (done) { + it('requests to Recurly the merchant Google Pay info with the initial options provided', function (done) { + this.stubRequestAndGoogleApi(); this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { - assert.equal(window.google.payments.api.PaymentsClient.called, false); + assert.equal(this.recurly.request.get.called, true); + assert.deepEqual(this.recurly.request.get.getCall(0).args[0], { + route: '/google_pay/info', + data: { + country: 'US', + currency: 'USD', + gateway_code : 'CODE_123', + }, + }); })); }); - it('do not emit any token nor the on ready event', function (done) { - const result = this.recurly.GooglePay(this.googlePayOpts); - - result.on('ready', () => done(new Error('expected to not emit a ready event'))); - result.on('token', () => done(new Error('expected to not emit a token event'))); - nextTick(done); - }); - }); - - context('when the requested merchant Google Pay info returns an empty list of payment methods', function () { - beforeEach(function () { - this.stubRequestOpts.info = Promise.resolve({ - siteMode: 'test', - paymentMethods: [], - }); - this.stubRequestAndGoogleApi(); - }); + context('when missing a required option', function () { + const requiredKeys = ['country', 'currency']; + requiredKeys.forEach(key => { + describe(`:${key}`, function () { + beforeEach(function () { + this.googlePayOpts[key] = undefined; + this.stubRequestAndGoogleApi(); + }); - it('emits a google-pay-not-configured error', function (done) { - const result = this.recurly.GooglePay(this.googlePayOpts); + it('emits a google-pay-config-missing error', function (done) { + const result = this.recurly.GooglePay(this.googlePayOpts); - result.on('error', (err) => assertDone(done, () => { - assert.ok(err); - assert.equal(err.code, 'google-pay-not-configured'); - assert.equal(err.message, 'There are no Payment Methods enabled to support Google Pay'); - })); - }); + result.on('error', (err) => assertDone(done, () => { + assert.ok(err); + assert.equal(err.code, 'google-pay-config-missing'); + assert.equal(err.message, `Missing Google Pay configuration option: '${key}'`); + })); + }); - it('do not initiate the pay-with-google', function (done) { - this.recurly.GooglePay(this.googlePayOpts); + it('do not initiate the pay-with-google nor requests to Recurly the merchant Google Pay info', function (done) { + this.recurly.GooglePay(this.googlePayOpts); - nextTick(() => assertDone(done, () => { - assert.equal(window.google.payments.api.PaymentsClient.called, false); - })); - }); + nextTick(() => assertDone(done, () => { + assert.equal(this.recurly.request.get.called, false); + assert.equal(window.google.payments.api.PaymentsClient.called, false); + })); + }); - it('do not emit any token nor the on ready event', function (done) { - const result = this.recurly.GooglePay(this.googlePayOpts); + it('do not emit any token nor the on ready event', function (done) { + const result = this.recurly.GooglePay(this.googlePayOpts); - result.on('ready', () => done(new Error('expected to not emit a ready event'))); - result.on('token', () => done(new Error('expected to not emit a token event'))); - nextTick(done); + result.on('ready', () => done(new Error('expected to not emit a ready event'))); + result.on('token', () => done(new Error('expected to not emit a token event'))); + nextTick(done); + }); + }); + }); }); - }); - - context('when the requested merchant Google Pay info returns a valid non-empty list of payment methods', function () { - it('initiates the pay-with-google with the expected Google Pay Configuration', function (done) { - this.stubRequestAndGoogleApi(); - this.recurly.GooglePay(this.googlePayOpts); - nextTick(() => assertDone(done, () => { - assert.equal(window.google.payments.api.PaymentsClient.called, true); - assert.deepEqual(window.google.payments.api.PaymentsClient.getCall(0).args[0], { - environment: 'TEST', - merchantInfo: { - merchantId: 'GOOGLE_MERCHANT_ID_123', - merchantName: 'RECURLY', - }, - paymentDataCallbacks: undefined, - }); - assert.deepEqual(window.google.payments.api.PaymentsClient.prototype.isReadyToPay.getCall(0).args[0], { - apiVersion: 2, - apiVersionMinor: 0, - allowedPaymentMethods: [ - { - type: 'CARD', - parameters: { - allowedCardNetworks: ['VISA'], - allowedAuthMethods: ['PAN_ONLY'], - billingAddressRequired: true, - billingAddressParameters: { - format: 'FULL', - }, - }, - tokenizationSpecification: { - type: 'PAYMENT_GATEWAY', - parameters: 'PAYMENT_GATEWAY_PARAMETERS', - }, - }, - { - type: 'CARD', - parameters: { - allowedCardNetworks: ['MASTERCARD'], - allowedAuthMethods: ['PAN_ONLY'], - billingAddressRequired: true, - billingAddressParameters: { - format: 'FULL', - }, - }, - tokenizationSpecification: { - type: 'DIRECT', - parameters: 'DIRECT_PARAMETERS', - }, - } - ], + if (isBraintreeIntegration) { + describe('when the libs are not loaded', function () { + beforeEach(function () { + delete window.braintree; + this.sandbox.stub(BraintreeLoader, 'loadModules').rejects(new Error('boom')); }); - })); - }); - context('when the site mode is production but an environment option is provided', function () { - beforeEach(function () { - this.stubRequestOpts.info = Promise.resolve({ - siteMode: 'production', - paymentMethods: this.paymentMethods, + it('load the libs', function (done) { + const result = this.recurly.GooglePay(this.googlePayOpts); + result.on('error', (err) => assertDone(done, () => { + assert(BraintreeLoader.loadModules.calledWith('googlePayment', 'dataCollector')); + assert.ok(err); + assert.equal(err.code, 'google-pay-init-error'); + assert.match(err.message, /boom/); + })); }); - this.googlePayOpts.environment = 'TEST'; }); - it('initiates the pay-with-google in the specified environment', function (done) { - this.stubRequestAndGoogleApi(); - this.recurly.GooglePay(this.googlePayOpts); + it('assigns the braintree configuration', function (done) { + const googlePay = this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { - assert.deepEqual(window.google.payments.api.PaymentsClient.getCall(0).args[0].environment, 'TEST'); + assert.ok(googlePay.braintree.dataCollector); + assert.ok(googlePay.braintree.googlePayment); })); }); - }); + } - context('when the site mode is production and no environment option is provided', function () { + context('when fails requesting to Recurly the merchant Google Pay info', function () { beforeEach(function () { - this.stubRequestOpts.info = Promise.resolve({ - siteMode: 'production', - paymentMethods: this.paymentMethods, - }); - this.googlePayOpts.environment = undefined; + this.stubRequestOpts.info = Promise.reject(recurlyError('api-error')); + this.stubRequestAndGoogleApi(); }); - it('initiates the pay-with-google in PRODUCTION mode', function (done) { - this.stubRequestAndGoogleApi(); - this.recurly.GooglePay(this.googlePayOpts); + it('emits an api-error', function (done) { + const result = this.recurly.GooglePay(this.googlePayOpts); - nextTick(() => assertDone(done, () => { - assert.deepEqual(window.google.payments.api.PaymentsClient.getCall(0).args[0].environment, 'PRODUCTION'); + result.on('error', (err) => assertDone(done, () => { + assert.ok(err); + assert.equal(err.code, 'api-error'); + assert.equal(err.message, 'There was an error with your request.'); })); }); - }); - - context('when the site mode is any other than production but an environment option is provided', function () { - beforeEach(function () { - this.stubRequestOpts.info = Promise.resolve({ - siteMode: 'sandbox', - paymentMethods: this.paymentMethods, - }); - this.googlePayOpts.environment = 'PRODUCTION'; - }); - it('initiates the pay-with-google in the specified environment', function (done) { - this.stubRequestAndGoogleApi(); + it('do not initiate the pay-with-google', function (done) { this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { - assert.deepEqual(window.google.payments.api.PaymentsClient.getCall(0).args[0].environment, 'PRODUCTION'); + assert.equal(window.google.payments.api.PaymentsClient.called, false); })); }); + + it('do not emit any token nor the on ready event', function (done) { + const result = this.recurly.GooglePay(this.googlePayOpts); + + result.on('ready', () => done(new Error('expected to not emit a ready event'))); + result.on('token', () => done(new Error('expected to not emit a token event'))); + nextTick(done); + }); }); - context('when the site mode is any other than production and no environment option is provided', function () { + context('when the requested merchant Google Pay info returns an empty list of payment methods', function () { beforeEach(function () { this.stubRequestOpts.info = Promise.resolve({ - siteMode: 'sandbox', - paymentMethods: this.paymentMethods, + siteMode: 'test', + paymentMethods: [], }); - this.googlePayOpts.environment = undefined; + this.stubRequestAndGoogleApi(); }); - it('initiates the pay-with-google in TEST mode', function (done) { - this.stubRequestAndGoogleApi(); - this.recurly.GooglePay(this.googlePayOpts); + it('emits a google-pay-not-configured error', function (done) { + const result = this.recurly.GooglePay(this.googlePayOpts); - nextTick(() => assertDone(done, () => { - assert.deepEqual(window.google.payments.api.PaymentsClient.getCall(0).args[0].environment, 'TEST'); + result.on('error', (err) => assertDone(done, () => { + assert.ok(err); + assert.equal(err.code, 'google-pay-not-configured'); + assert.equal(err.message, 'There are no Payment Methods enabled to support Google Pay'); })); }); - }); - context('options.billingAddressRequired = false', function () { - beforeEach(function () { - this.googlePayOpts.billingAddressRequired = false; - }); - - it('initiates the pay-with-google without the billing address requirement', function (done) { - this.stubRequestAndGoogleApi(); + it('do not initiate the pay-with-google', function (done) { this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { - const { allowedPaymentMethods: [{ parameters }] } = window.google.payments.api.PaymentsClient.prototype.isReadyToPay.getCall(0).args[0]; - assert.deepEqual(parameters.billingAddressRequired, undefined); - assert.deepEqual(parameters.billingAddressParameters, undefined); + assert.equal(window.google.payments.api.PaymentsClient.called, false); })); }); - }); - context('@deprecated options.requireBillingAddress = false', function () { - beforeEach(function () { - delete this.googlePayOpts.billingAddressRequired; - this.googlePayOpts.requireBillingAddress = false; + it('do not emit any token nor the on ready event', function (done) { + const result = this.recurly.GooglePay(this.googlePayOpts); + + result.on('ready', () => done(new Error('expected to not emit a ready event'))); + result.on('token', () => done(new Error('expected to not emit a token event'))); + nextTick(done); }); + }); - it('initiates the pay-with-google without the billing address requirement', function (done) { + context('when the requested merchant Google Pay info returns a valid non-empty list of payment methods', function () { + it('initiates the pay-with-google with the expected Google Pay Configuration', function (done) { this.stubRequestAndGoogleApi(); this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { - const { allowedPaymentMethods: [{ parameters }] } = window.google.payments.api.PaymentsClient.prototype.isReadyToPay.getCall(0).args[0]; - assert.deepEqual(parameters.billingAddressRequired, undefined); - assert.deepEqual(parameters.billingAddressParameters, undefined); + assert.equal(window.google.payments.api.PaymentsClient.called, true); + assert.deepEqual(window.google.payments.api.PaymentsClient.getCall(0).args[0], { + environment: 'TEST', + merchantInfo: { + merchantId: 'GOOGLE_MERCHANT_ID_123', + merchantName: 'RECURLY', + }, + paymentDataCallbacks: undefined, + }); + + if (isDirectIntegration) { + assert.deepEqual(window.google.payments.api.PaymentsClient.prototype.isReadyToPay.getCall(0).args[0], { + apiVersion: 2, + apiVersionMinor: 0, + allowedPaymentMethods: [ + { + type: 'CARD', + parameters: { + allowedCardNetworks: ['VISA'], + allowedAuthMethods: ['PAN_ONLY'], + billingAddressRequired: true, + billingAddressParameters: { + format: 'FULL', + }, + }, + tokenizationSpecification: { + type: 'PAYMENT_GATEWAY', + parameters: 'PAYMENT_GATEWAY_PARAMETERS', + }, + }, + { + type: 'CARD', + parameters: { + allowedCardNetworks: ['MASTERCARD'], + allowedAuthMethods: ['PAN_ONLY'], + billingAddressRequired: true, + billingAddressParameters: { + format: 'FULL', + }, + }, + tokenizationSpecification: { + type: 'DIRECT', + parameters: 'DIRECT_PARAMETERS', + }, + } + ], + }); + } else if (isBraintreeIntegration) { + assert.deepEqual(window.google.payments.api.PaymentsClient.prototype.isReadyToPay.getCall(0).args[0], { + apiVersion: 2, + apiVersionMinor: 0, + allowedPaymentMethods: [ + { + type: 'CARD', + parameters: { + allowedCardNetworks: ['VISA'], + allowedAuthMethods: ['PAN_ONLY'], + billingAddressRequired: true, + billingAddressParameters: { + format: 'FULL', + }, + }, + tokenizationSpecification: { + type: 'PAYMENT_GATEWAY', + parameters: { gateway: 'braintree' }, + }, + }, + { + type: 'CARD', + parameters: { + allowedCardNetworks: ['MASTERCARD'], + allowedAuthMethods: ['PAN_ONLY'], + billingAddressRequired: true, + billingAddressParameters: { + format: 'FULL', + }, + }, + tokenizationSpecification: { + type: 'PAYMENT_GATEWAY', + parameters: { gateway: 'braintree' }, + }, + } + ], + }); + } else { + throw new Error('Invalid integration type'); + } })); }); - }); - context('with options.paymentDataRequest attributes', function () { - it('merges them into the actual paymentDataRequest', function (done) { - this.stubRequestAndGoogleApi(); - const merchantInfo = { - merchantId: 'GOOGLE_MERCHANT_ID_123', - merchantName: 'RECURLY', - }; - const transactionInfo = { - currencyCode: 'USD', - countryCode: 'US', - totalPrice: '1', - }; - - this.recurly.GooglePay({ - ...this.googlePayOpts, - billingAddressRequired: false, - paymentDataRequest: { - emailRequired: true, - shippingAddressRequired: true, - shippingOptionRequired: true, - shippingOptionParameters: [], - shippingAddressParameters: [], - }, + context('when the site mode is production but an environment option is provided', function () { + beforeEach(function () { + this.stubRequestOpts.info = Promise.resolve({ + siteMode: 'production', + paymentMethods: this.paymentMethods, + }); + this.googlePayOpts.environment = 'TEST'; }); - nextTick(() => assertDone(done, () => { - const paymentDataRequest = window.google.payments.api.PaymentsClient.prototype.prefetchPaymentData.getCall(0).args[0]; - assert.deepEqual(paymentDataRequest.merchantInfo, merchantInfo); - assert.deepEqual(paymentDataRequest.transactionInfo, { totalPriceStatus: 'NOT_CURRENTLY_KNOWN', ...transactionInfo }); - assert.equal(paymentDataRequest.emailRequired, true); - assert.equal(paymentDataRequest.shippingAddressRequired, true); - assert.equal(paymentDataRequest.shippingOptionRequired, true); - assert.equal(paymentDataRequest.shippingOptionParameters.length, 0); - assert.equal(paymentDataRequest.shippingAddressParameters.length, 0); - })); + it('initiates the pay-with-google in the specified environment', function (done) { + this.stubRequestAndGoogleApi(); + this.recurly.GooglePay(this.googlePayOpts); + + nextTick(() => assertDone(done, () => { + assert.deepEqual(window.google.payments.api.PaymentsClient.getCall(0).args[0].environment, 'TEST'); + })); + }); }); - it('uses them if not provided at the top level', function (done) { - this.stubRequestAndGoogleApi(); - const merchantInfo = { - merchantId: 'GOOGLE_MERCHANT_ID_123', - merchantName: 'RECURLY', - }; - const transactionInfo = { - currencyCode: 'USD', - countryCode: 'US', - totalPrice: '1', - }; - - this.recurly.GooglePay({ - billingAddressRequired: false, - paymentDataRequest: { - merchantInfo, - transactionInfo, - emailRequired: true, - shippingAddressRequired: true, - shippingOptionRequired: true, - shippingOptionParameters: [], - shippingAddressParameters: [], - }, + context('when the site mode is production and no environment option is provided', function () { + beforeEach(function () { + this.stubRequestOpts.info = Promise.resolve({ + siteMode: 'production', + paymentMethods: this.paymentMethods, + }); + this.googlePayOpts.environment = undefined; }); - nextTick(() => assertDone(done, () => { - const paymentDataRequest = window.google.payments.api.PaymentsClient.prototype.prefetchPaymentData.getCall(0).args[0]; - assert.deepEqual(paymentDataRequest.merchantInfo, merchantInfo); - assert.deepEqual(paymentDataRequest.transactionInfo, { totalPriceStatus: 'NOT_CURRENTLY_KNOWN', ...transactionInfo }); - assert.equal(paymentDataRequest.emailRequired, true); - assert.equal(paymentDataRequest.shippingAddressRequired, true); - assert.equal(paymentDataRequest.shippingOptionRequired, true); - assert.equal(paymentDataRequest.shippingOptionParameters.length, 0); - assert.equal(paymentDataRequest.shippingAddressParameters.length, 0); - })); + it('initiates the pay-with-google in PRODUCTION mode', function (done) { + this.stubRequestAndGoogleApi(); + this.recurly.GooglePay(this.googlePayOpts); + + nextTick(() => assertDone(done, () => { + assert.deepEqual(window.google.payments.api.PaymentsClient.getCall(0).args[0].environment, 'PRODUCTION'); + })); + }); }); - }); - context('with options.callbacks', function () { - it('handles the shipping address intent if onPaymentDataChanged is provided and requiring shipping address', function (done) { - this.stubRequestAndGoogleApi(); - const callbacks = { onPaymentDataChanged: () => {} }; - this.recurly.GooglePay({ - ...this.googlePayOpts, - callbacks, - paymentDataRequest: { - shippingAddressRequired: true, - }, + context('when the site mode is any other than production but an environment option is provided', function () { + beforeEach(function () { + this.stubRequestOpts.info = Promise.resolve({ + siteMode: 'sandbox', + paymentMethods: this.paymentMethods, + }); + this.googlePayOpts.environment = 'PRODUCTION'; }); - nextTick(() => assertDone(done, () => { - assert.equal(window.google.payments.api.PaymentsClient.getCall(0).args[0].paymentDataCallbacks, callbacks); - const { callbackIntents } = window.google.payments.api.PaymentsClient.prototype.prefetchPaymentData.getCall(0).args[0]; - assert.deepEqual(callbackIntents, ['SHIPPING_ADDRESS']); - })); + it('initiates the pay-with-google in the specified environment', function (done) { + this.stubRequestAndGoogleApi(); + this.recurly.GooglePay(this.googlePayOpts); + + nextTick(() => assertDone(done, () => { + assert.deepEqual(window.google.payments.api.PaymentsClient.getCall(0).args[0].environment, 'PRODUCTION'); + })); + }); }); - it('handles the shipping option intent if onPaymentDataChanged is provided and requiring shipping option', function (done) { - this.stubRequestAndGoogleApi(); - const callbacks = { onPaymentDataChanged: () => {} }; - this.recurly.GooglePay({ - ...this.googlePayOpts, - callbacks, - paymentDataRequest: { - shippingOptionRequired: true, - }, + context('when the site mode is any other than production and no environment option is provided', function () { + beforeEach(function () { + this.stubRequestOpts.info = Promise.resolve({ + siteMode: 'sandbox', + paymentMethods: this.paymentMethods, + }); + this.googlePayOpts.environment = undefined; }); - nextTick(() => assertDone(done, () => { - assert.equal(window.google.payments.api.PaymentsClient.getCall(0).args[0].paymentDataCallbacks, callbacks); - const { callbackIntents } = window.google.payments.api.PaymentsClient.prototype.prefetchPaymentData.getCall(0).args[0]; - assert.deepEqual(callbackIntents, ['SHIPPING_OPTION']); - })); + it('initiates the pay-with-google in TEST mode', function (done) { + this.stubRequestAndGoogleApi(); + this.recurly.GooglePay(this.googlePayOpts); + + nextTick(() => assertDone(done, () => { + assert.deepEqual(window.google.payments.api.PaymentsClient.getCall(0).args[0].environment, 'TEST'); + })); + }); }); - context('with onPaymentAuthorized provided', function () { + context('options.billingAddressRequired = false', function () { beforeEach(function () { - this.stubRequestAndGoogleApi(); - this.clickGooglePayButton = (emitter, done) => { - emitter.on('ready', button => { - nextTick(() => { - const { onPaymentAuthorized } = window.google.payments.api.PaymentsClient.getCall(0).args[0].paymentDataCallbacks; - button.click().then(onPaymentAuthorized).then(done); - }); - }); - }; + this.googlePayOpts.billingAddressRequired = false; }); - it('handles the payment authorized intent', function (done) { - const callbacks = { onPaymentAuthorized: () => {} }; - this.recurly.GooglePay({ - ...this.googlePayOpts, - callbacks, - }); + it('initiates the pay-with-google without the billing address requirement', function (done) { + this.stubRequestAndGoogleApi(); + this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { - assert.equal(window.google.payments.api.PaymentsClient.getCall(0).args[0].paymentDataCallbacks.onPaymentAuthorized === undefined, false); - const { callbackIntents } = window.google.payments.api.PaymentsClient.prototype.prefetchPaymentData.getCall(0).args[0]; - assert.deepEqual(callbackIntents, ['PAYMENT_AUTHORIZATION']); + const { allowedPaymentMethods: [{ parameters }] } = window.google.payments.api.PaymentsClient.prototype.isReadyToPay.getCall(0).args[0]; + assert.deepEqual(parameters.billingAddressRequired, undefined); + assert.deepEqual(parameters.billingAddressParameters, undefined); })); }); + }); + + context('@deprecated options.requireBillingAddress = false', function () { + beforeEach(function () { + delete this.googlePayOpts.billingAddressRequired; + this.googlePayOpts.requireBillingAddress = false; + }); - it('is called after the button is clicked with the paymentData and token', function (done) { - let paymentData; - const emitter = this.recurly.GooglePay({ - ...this.googlePayOpts, - callbacks: { onPaymentAuthorized: (pd) => paymentData = pd }, - }); + it('initiates the pay-with-google without the billing address requirement', function (done) { + this.stubRequestAndGoogleApi(); + this.recurly.GooglePay(this.googlePayOpts); - this.clickGooglePayButton(emitter, (res) => assertDone(done, () => { - assert.equal(res.transactionState, 'SUCCESS'); - assert.equal(res.error, undefined); - assert.equal(paymentData.recurlyToken.id, 'TOKEN_123'); + nextTick(() => assertDone(done, () => { + const { allowedPaymentMethods: [{ parameters }] } = window.google.payments.api.PaymentsClient.prototype.isReadyToPay.getCall(0).args[0]; + assert.deepEqual(parameters.billingAddressRequired, undefined); + assert.deepEqual(parameters.billingAddressParameters, undefined); })); }); + }); - it('allows for errors from fetching the token', function (done) { - this.recurly.request.post.restore(); - this.sandbox.stub(this.recurly.request, 'post').rejects('boom'); + context('with options.paymentDataRequest attributes', function () { + it('merges them into the actual paymentDataRequest', function (done) { + this.stubRequestAndGoogleApi(); + const merchantInfo = { + merchantId: 'GOOGLE_MERCHANT_ID_123', + merchantName: 'RECURLY', + }; + const transactionInfo = { + currencyCode: 'USD', + countryCode: 'US', + totalPrice: '1', + }; - const onPaymentAuthorized = this.sandbox.stub(); - const emitter = this.recurly.GooglePay({ + this.recurly.GooglePay({ ...this.googlePayOpts, - callbacks: { onPaymentAuthorized }, + billingAddressRequired: false, + paymentDataRequest: { + emailRequired: true, + shippingAddressRequired: true, + shippingOptionRequired: true, + shippingOptionParameters: [], + shippingAddressParameters: [], + }, }); - this.clickGooglePayButton(emitter, (res) => assertDone(done, () => { - assert.equal(res.transactionState, 'ERROR'); - assert.deepEqual(res.error, { - reason: 'OTHER_ERROR', - message: 'Error processing payment information, please try again later', - intent: 'PAYMENT_AUTHORIZATION' - }); - assert(!onPaymentAuthorized.called, 'onPaymentAuthorized should not be called'); + nextTick(() => assertDone(done, () => { + const paymentDataRequest = window.google.payments.api.PaymentsClient.prototype.prefetchPaymentData.getCall(0).args[0]; + assert.deepEqual(paymentDataRequest.merchantInfo, merchantInfo); + assert.deepEqual(paymentDataRequest.transactionInfo, { totalPriceStatus: 'NOT_CURRENTLY_KNOWN', ...transactionInfo }); + assert.equal(paymentDataRequest.emailRequired, true); + assert.equal(paymentDataRequest.shippingAddressRequired, true); + assert.equal(paymentDataRequest.shippingOptionRequired, true); + assert.equal(paymentDataRequest.shippingOptionParameters.length, 0); + assert.equal(paymentDataRequest.shippingAddressParameters.length, 0); })); }); - it('allows for errors to be passed back', function (done) { - const error = { - reason: 'PAYMENT_DATA_INVALID', - message: 'Cannot pay with payment credentials', - intent: 'PAYMENT_AUTHORIZATION', + it('uses them if not provided at the top level', function (done) { + this.stubRequestAndGoogleApi(); + const merchantInfo = { + merchantId: 'GOOGLE_MERCHANT_ID_123', + merchantName: 'RECURLY', }; - const emitter = this.recurly.GooglePay({ - ...this.googlePayOpts, - callbacks: { onPaymentAuthorized: () => ({ error }) }, + const transactionInfo = { + currencyCode: 'USD', + countryCode: 'US', + totalPrice: '1', + }; + + this.recurly.GooglePay({ + billingAddressRequired: false, + paymentDataRequest: { + merchantInfo, + transactionInfo, + emailRequired: true, + shippingAddressRequired: true, + shippingOptionRequired: true, + shippingOptionParameters: [], + shippingAddressParameters: [], + }, }); - this.clickGooglePayButton(emitter, (res) => assertDone(done, () => { - assert.equal(res.transactionState, 'ERROR'); - assert.deepEqual(res.error, error); + nextTick(() => assertDone(done, () => { + const paymentDataRequest = window.google.payments.api.PaymentsClient.prototype.prefetchPaymentData.getCall(0).args[0]; + assert.deepEqual(paymentDataRequest.merchantInfo, merchantInfo); + assert.deepEqual(paymentDataRequest.transactionInfo, { totalPriceStatus: 'NOT_CURRENTLY_KNOWN', ...transactionInfo }); + assert.equal(paymentDataRequest.emailRequired, true); + assert.equal(paymentDataRequest.shippingAddressRequired, true); + assert.equal(paymentDataRequest.shippingOptionRequired, true); + assert.equal(paymentDataRequest.shippingOptionParameters.length, 0); + assert.equal(paymentDataRequest.shippingAddressParameters.length, 0); })); }); }); - }); - context('when cannot proceed with the pay-with-google', function () { - context('when the GooglePay is not available', function () { - beforeEach(function () { - this.stubGoogleAPIOpts.isReadyToPay = Promise.resolve({ result: false }); + context('with options.callbacks', function () { + it('handles the shipping address intent if onPaymentDataChanged is provided and requiring shipping address', function (done) { this.stubRequestAndGoogleApi(); + const callbacks = { onPaymentDataChanged: () => {} }; + this.recurly.GooglePay({ + ...this.googlePayOpts, + callbacks, + paymentDataRequest: { + shippingAddressRequired: true, + }, + }); + + nextTick(() => assertDone(done, () => { + assert.equal(window.google.payments.api.PaymentsClient.getCall(0).args[0].paymentDataCallbacks, callbacks); + const { callbackIntents } = window.google.payments.api.PaymentsClient.prototype.prefetchPaymentData.getCall(0).args[0]; + assert.deepEqual(callbackIntents, ['SHIPPING_ADDRESS']); + })); }); - it('emits the same error the pay-with-google throws', function (done) { - const result = this.recurly.GooglePay(this.googlePayOpts); + it('handles the shipping option intent if onPaymentDataChanged is provided and requiring shipping option', function (done) { + this.stubRequestAndGoogleApi(); + const callbacks = { onPaymentDataChanged: () => {} }; + this.recurly.GooglePay({ + ...this.googlePayOpts, + callbacks, + paymentDataRequest: { + shippingOptionRequired: true, + }, + }); - result.on('error', (err) => assertDone(done, () => { - assert.ok(err); - assert.equal(err.code, 'google-pay-not-available'); - assert.equal(err.message, 'Google Pay is not available'); + nextTick(() => assertDone(done, () => { + assert.equal(window.google.payments.api.PaymentsClient.getCall(0).args[0].paymentDataCallbacks, callbacks); + const { callbackIntents } = window.google.payments.api.PaymentsClient.prototype.prefetchPaymentData.getCall(0).args[0]; + assert.deepEqual(callbackIntents, ['SHIPPING_OPTION']); })); }); - it('do not emit any token nor the on ready event', function (done) { - const result = this.recurly.GooglePay(this.googlePayOpts); + context('with onPaymentAuthorized provided', function () { + beforeEach(function () { + this.stubRequestAndGoogleApi(); + this.clickGooglePayButton = (emitter, done) => { + emitter.on('ready', button => { + nextTick(() => { + const { onPaymentAuthorized } = window.google.payments.api.PaymentsClient.getCall(0).args[0].paymentDataCallbacks; + button.click().then(onPaymentAuthorized).then(done); + }); + }); + }; + }); - result.on('ready', () => done(new Error('expected to not emit a ready event'))); - result.on('token', () => done(new Error('expected to not emit a token event'))); - nextTick(done); - }); - }); + it('handles the payment authorized intent', function (done) { + const callbacks = { onPaymentAuthorized: () => {} }; + this.recurly.GooglePay({ + ...this.googlePayOpts, + callbacks, + }); - context('when the GooglePay is available but does not support user cards', function () { - beforeEach(function () { - this.googlePayOpts.existingPaymentMethodRequired = true; - this.stubGoogleAPIOpts.isReadyToPay = Promise.resolve({ result: true, paymentMethodPresent: false }); - this.stubRequestAndGoogleApi(); - }); + nextTick(() => assertDone(done, () => { + assert.equal(window.google.payments.api.PaymentsClient.getCall(0).args[0].paymentDataCallbacks.onPaymentAuthorized === undefined, false); + const { callbackIntents } = window.google.payments.api.PaymentsClient.prototype.prefetchPaymentData.getCall(0).args[0]; + assert.deepEqual(callbackIntents, ['PAYMENT_AUTHORIZATION']); + })); + }); - it('initiates pay-with-google with the expected Google Pay Configuration', function (done) { - this.recurly.GooglePay(this.googlePayOpts); + it('is called after the button is clicked with the paymentData and token', function (done) { + let paymentData; + const emitter = this.recurly.GooglePay({ + ...this.googlePayOpts, + callbacks: { onPaymentAuthorized: (pd) => paymentData = pd }, + }); - nextTick(() => assertDone(done, () => { - assert.equal(window.google.payments.api.PaymentsClient.called, true); - const isReadyToPayRequest = window.google.payments.api.PaymentsClient.prototype.isReadyToPay.getCall(0).args[0]; - assert.equal(isReadyToPayRequest.existingPaymentMethodRequired, true); - })); - }); + this.clickGooglePayButton(emitter, (res) => assertDone(done, () => { + assert.equal(res.transactionState, 'SUCCESS'); + assert.equal(res.error, undefined); + assert.equal(paymentData.recurlyToken.id, 'TOKEN_123'); + })); + }); - it('emits the same error the pay-with-google throws', function (done) { - const result = this.recurly.GooglePay(this.googlePayOpts); + it('allows for errors from fetching the token', function (done) { + this.recurly.request.post.restore(); + this.sandbox.stub(this.recurly.request, 'post').rejects('boom'); - result.on('error', (err) => assertDone(done, () => { - assert.ok(err); - assert.equal(err.code, 'google-pay-not-available'); - assert.equal(err.message, 'Google Pay is not available'); - })); - }); + const onPaymentAuthorized = this.sandbox.stub(); + const emitter = this.recurly.GooglePay({ + ...this.googlePayOpts, + callbacks: { onPaymentAuthorized }, + }); - it('do not emit any token nor the on ready event', function (done) { - const result = this.recurly.GooglePay(this.googlePayOpts); + this.clickGooglePayButton(emitter, (res) => assertDone(done, () => { + assert.equal(res.transactionState, 'ERROR'); + assert.deepEqual(res.error, { + reason: 'OTHER_ERROR', + message: 'Error processing payment information, please try again later', + intent: 'PAYMENT_AUTHORIZATION' + }); + assert(!onPaymentAuthorized.called, 'onPaymentAuthorized should not be called'); + })); + }); - result.on('ready', () => done(new Error('expected to not emit a ready event'))); - result.on('token', () => done(new Error('expected to not emit a token event'))); - nextTick(done); + it('allows for errors to be passed back', function (done) { + const error = { + reason: 'PAYMENT_DATA_INVALID', + message: 'Cannot pay with payment credentials', + intent: 'PAYMENT_AUTHORIZATION', + }; + const emitter = this.recurly.GooglePay({ + ...this.googlePayOpts, + callbacks: { onPaymentAuthorized: () => ({ error }) }, + }); + + this.clickGooglePayButton(emitter, (res) => assertDone(done, () => { + assert.equal(res.transactionState, 'ERROR'); + assert.deepEqual(res.error, error); + })); + }); }); }); - }); - context('when the pay-with-google success', function () { - it('emits the ready event with the google-pay button', function (done) { - this.stubRequestAndGoogleApi(); - const result = this.recurly.GooglePay(this.googlePayOpts); + context('when cannot proceed with the pay-with-google', function () { + context('when the GooglePay is not available', function () { + beforeEach(function () { + this.stubGoogleAPIOpts.isReadyToPay = Promise.resolve({ result: false }); + this.stubRequestAndGoogleApi(); + }); - result.on('ready', button => assertDone(done, () => { - assert.ok(button); - })); - }); + it('emits the same error the pay-with-google throws', function (done) { + const result = this.recurly.GooglePay(this.googlePayOpts); - context('when the google-pay button is clicked', function () { - beforeEach(function () { - this.clickGooglePayButton = (cb) => { - this.stubRequestAndGoogleApi(); + result.on('error', (err) => assertDone(done, () => { + assert.ok(err); + assert.equal(err.code, 'google-pay-not-available'); + assert.equal(err.message, 'Google Pay is not available'); + })); + }); + + it('do not emit any token nor the on ready event', function (done) { const result = this.recurly.GooglePay(this.googlePayOpts); - result.on('ready', button => { - cb(result); - nextTick(() => button.click()); - }); - }; + result.on('ready', () => done(new Error('expected to not emit a ready event'))); + result.on('token', () => done(new Error('expected to not emit a token event'))); + nextTick(done); + }); }); - it('requests the user Payment Data', function (done) { - this.clickGooglePayButton(result => { - result.on('token', () => assertDone(done, () => { - assert.equal(window.google.payments.api.PaymentsClient.prototype.loadPaymentData.called, true); + context('when the GooglePay is available but does not support user cards', function () { + beforeEach(function () { + this.googlePayOpts.existingPaymentMethodRequired = true; + this.stubGoogleAPIOpts.isReadyToPay = Promise.resolve({ result: true, paymentMethodPresent: false }); + this.stubRequestAndGoogleApi(); + }); + + it('initiates pay-with-google with the expected Google Pay Configuration', function (done) { + this.recurly.GooglePay(this.googlePayOpts); + + nextTick(() => assertDone(done, () => { + assert.equal(window.google.payments.api.PaymentsClient.called, true); + const isReadyToPayRequest = window.google.payments.api.PaymentsClient.prototype.isReadyToPay.getCall(0).args[0]; + assert.equal(isReadyToPayRequest.existingPaymentMethodRequired, true); + })); + }); + + it('emits the same error the pay-with-google throws', function (done) { + const result = this.recurly.GooglePay(this.googlePayOpts); + + result.on('error', (err) => assertDone(done, () => { + assert.ok(err); + assert.equal(err.code, 'google-pay-not-available'); + assert.equal(err.message, 'Google Pay is not available'); })); }); + + it('do not emit any token nor the on ready event', function (done) { + const result = this.recurly.GooglePay(this.googlePayOpts); + + result.on('ready', () => done(new Error('expected to not emit a ready event'))); + result.on('token', () => done(new Error('expected to not emit a token event'))); + nextTick(done); + }); }); + }); - context('when fails retrieving the user Payment Data', function () { + context('when the pay-with-google success', function () { + it('emits the ready event with the google-pay button', function (done) { + this.stubRequestAndGoogleApi(); + const result = this.recurly.GooglePay(this.googlePayOpts); + + result.on('ready', button => assertDone(done, () => { + assert.ok(button); + })); + }); + + context('when the google-pay button is clicked', function () { beforeEach(function () { - this.stubGoogleAPIOpts.loadPaymentData = Promise.reject('boom'); - }); + this.clickGooglePayButton = (cb) => { + this.stubRequestAndGoogleApi(); + const result = this.recurly.GooglePay(this.googlePayOpts); - it('emits the same error that the retrieving process throws', function (done) { - this.clickGooglePayButton(result => { - result.on('error', err => { - assertDone(done, () => { - assert.ok(err); - assert.equal(err.code, 'google-pay-payment-failure'); - assert.equal(err.message, 'Google Pay could not get the Payment Data'); - }); + result.on('ready', button => { + cb(result); + nextTick(() => button.click()); }); - }); + }; }); - it('do not request any token to Recurly', function (done) { + it('requests the user Payment Data', function (done) { this.clickGooglePayButton(result => { - result.on('token', () => done(new Error('expected to not emit the token'))); - result.on('error', () => assertDone(done, () => { - assert.equal(this.recurly.request.post.called, false); + result.on('token', () => assertDone(done, () => { + assert.equal(window.google.payments.api.PaymentsClient.prototype.loadPaymentData.called, true); })); }); }); - }); - context('when success retrieving the user Payment Data', function () { - it('request to Recurly to create the token with the billing address from the user Payment Data', function (done) { - this.clickGooglePayButton(result => { - result.on('token', () => assertDone(done, () => { - assert.equal(this.recurly.request.post.called, true); - - assert.deepEqual(this.recurly.request.post.getCall(0).args[0], { - route: '/google_pay/token', - data: { - first_name: 'John', - last_name: 'Smith', - country: 'US', - state: 'CA', - city: 'Mountain View', - postal_code: '94043', - address1: '1600 Amphitheatre Parkway', - address2: '', - paymentData: { - paymentMethodData: { - description: 'Visa •••• 1111', - tokenizationData: { - type: 'PAYMENT_GATEWAY', - token: '{"id": "tok_123"}', - }, - type: 'CARD', - info: { - cardNetwork: 'VISA', - cardDetails: '1111', - billingAddress: { - address3: '', - sortingCode: '', - address2: '', - countryCode: 'US', - address1: '1600 Amphitheatre Parkway', - postalCode: '94043', - name: 'John Smith', - locality: 'Mountain View', - administrativeArea: 'CA', - }, - }, - }, - }, - gateway_code: 'gateway_123', - } + context('when fails retrieving the user Payment Data', function () { + beforeEach(function () { + this.stubGoogleAPIOpts.loadPaymentData = Promise.reject('boom'); + }); + + it('emits the same error that the retrieving process throws', function (done) { + this.clickGooglePayButton(result => { + result.on('error', err => { + assertDone(done, () => { + assert.ok(err); + assert.equal(err.code, 'google-pay-payment-failure'); + assert.equal(err.message, 'Google Pay could not get the Payment Data'); + }); }); - })); + }); }); - }); - context('when the user provide a
with custom billing address', function () { - beforeEach(function () { - this.googlePayOpts.form = { - first_name: 'Frank', - last_name: 'Isaac', - country: 'RF', - state: '', - city: '', - postal_code: '123', - address1: '', - address2: '', - }; + it('do not request any token to Recurly', function (done) { + this.clickGooglePayButton(result => { + result.on('token', () => done(new Error('expected to not emit the token'))); + result.on('error', () => assertDone(done, () => { + assert.equal(this.recurly.request.post.called, false); + })); + }); }); + }); - it('request to Recurly to create the token with the billing address from the ', function (done) { + context('when success retrieving the user Payment Data', function () { + it('request to Recurly to create the token with the billing address from the user Payment Data', function (done) { this.clickGooglePayButton(result => { result.on('token', () => assertDone(done, () => { assert.equal(this.recurly.request.post.called, true); - - assert.deepEqual(this.recurly.request.post.getCall(0).args[0], { + let expectedCall = { route: '/google_pay/token', data: { - first_name: 'Frank', - last_name: 'Isaac', - country: 'RF', - state: '', - city: '', - postal_code: '123', - address1: '', + first_name: 'John', + last_name: 'Smith', + country: 'US', + state: 'CA', + city: 'Mountain View', + postal_code: '94043', + address1: '1600 Amphitheatre Parkway', address2: '', paymentData: { paymentMethodData: { @@ -770,45 +819,88 @@ describe('Google Pay', function () { }, gateway_code: 'gateway_123', } - }); + }; + + if (isBraintreeIntegration) { + expectedCall.data.type = 'braintree'; + expectedCall.data.paymentData.paymentMethodData.gatewayToken = { + nonce: 'GATEWAY_TOKEN', + deviceData: 'DEVICE_DATA', + }; + } + + assert.deepEqual(this.recurly.request.post.getCall(0).args[0], expectedCall); })); }); }); - }); - context('when Recurly fails creating the token', function () { - beforeEach(function () { - this.stubRequestOpts.token = Promise.reject(recurlyError('api-error')); - }); + context('when the user provide a with custom billing address', function () { + beforeEach(function () { + this.googlePayOpts.form = { + first_name: 'Frank', + last_name: 'Isaac', + country: 'RF', + state: '', + city: '', + postal_code: '123', + address1: '', + address2: '', + }; + }); - it('emits an api-error', function (done) { - this.clickGooglePayButton(result => { - result.on('error', err => assertDone(done, () => { - assert.ok(err); - assert.equal(err.code, 'api-error'); - assert.equal(err.message, 'There was an error with your request.'); - })); + it('request to Recurly to create the token with the billing address from the ', function (done) { + this.clickGooglePayButton(result => { + result.on('token', () => assertDone(done, () => { + assert.equal(this.recurly.request.post.called, true); + + const callData = this.recurly.request.post.getCall(0).args[0].data; + assert.equal(callData.first_name, 'Frank'); + assert.equal(callData.last_name, 'Isaac'); + assert.equal(callData.country, 'RF'); + assert.equal(callData.state, ''); + assert.equal(callData.city, ''); + assert.equal(callData.postal_code, '123'); + assert.equal(callData.address1, ''); + assert.equal(callData.address2, ''); + })); + }); }); }); - it('do not emit any token', function (done) { - this.clickGooglePayButton(result => { - result.on('token', () => done(new Error('expected to not emit a token event'))); + context('when Recurly fails creating the token', function () { + beforeEach(function () { + this.stubRequestOpts.token = Promise.reject(recurlyError('api-error')); + }); - nextTick(done); + it('emits an api-error', function (done) { + this.clickGooglePayButton(result => { + result.on('error', err => assertDone(done, () => { + assert.ok(err); + assert.equal(err.code, 'api-error'); + assert.equal(err.message, 'There was an error with your request.'); + })); + }); + }); + + it('do not emit any token', function (done) { + this.clickGooglePayButton(result => { + result.on('token', () => done(new Error('expected to not emit a token event'))); + + nextTick(done); + }); }); }); - }); - context('when Recurly success creating the token', function () { - it('emits the token', function (done) { - this.clickGooglePayButton(result => { - result.on('token', (token) => assertDone(done, () => { - assert.ok(token); - assert.deepEqual(token, { - id: 'TOKEN_123', - }); - })); + context('when Recurly success creating the token', function () { + it('emits the token', function (done) { + this.clickGooglePayButton(result => { + result.on('token', (token) => assertDone(done, () => { + assert.ok(token); + assert.deepEqual(token, { + id: 'TOKEN_123', + }); + })); + }); }); }); }); @@ -816,4 +908,4 @@ describe('Google Pay', function () { }); }); }); -}); +} diff --git a/types/lib/google-pay/index.d.ts b/types/lib/google-pay/index.d.ts index 3d27f3c3..6f76a748 100644 --- a/types/lib/google-pay/index.d.ts +++ b/types/lib/google-pay/index.d.ts @@ -81,6 +81,13 @@ export type GooglePayOptions = { */ gatewayCode?: string; + /** + * If provided, will use Braintree to process the GooglePay transaction. + */ + braintree?: { + clientAuthorization: string; + }; + /** * Specify configuration for Google Pay API. */