From a11a7a19c321bfaf7c7083202d4e44282f7b2ea4 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 17 Jun 2022 11:27:02 -0300 Subject: [PATCH] adding support to google-pay with payment-gateway integration --- lib/recurly.js | 2 + lib/recurly/errors.js | 38 ++ lib/recurly/google-pay/google-pay.js | 155 ++++++ lib/recurly/google-pay/index.js | 13 + lib/recurly/google-pay/pay-with-google.js | 50 ++ lib/util/dom.js | 12 +- test/unit/google-pay/google-pay.test.js | 509 +++++++++++++++++++ test/unit/google-pay/pay-with-google.test.js | 225 ++++++++ test/unit/support/helpers.js | 67 +++ types/lib/google-pay.d.ts | 64 +++ 10 files changed, 1134 insertions(+), 1 deletion(-) create mode 100644 lib/recurly/google-pay/google-pay.js create mode 100644 lib/recurly/google-pay/index.js create mode 100644 lib/recurly/google-pay/pay-with-google.js create mode 100644 test/unit/google-pay/google-pay.test.js create mode 100644 test/unit/google-pay/pay-with-google.test.js create mode 100644 types/lib/google-pay.d.ts diff --git a/lib/recurly.js b/lib/recurly.js index 784f3af37..2ca95dc85 100644 --- a/lib/recurly.js +++ b/lib/recurly.js @@ -17,6 +17,7 @@ import version from './recurly/version'; import { tokenDispatcher as token } from './recurly/token'; import { factory as Adyen } from './recurly/adyen'; import { factory as ApplePay } from './recurly/apple-pay'; +import { factory as GooglePay } from './recurly/google-pay'; import { factory as BankRedirect } from './recurly/bank-redirect'; import { factory as Elements } from './recurly/elements'; import { factory as Frame } from './recurly/frame'; @@ -107,6 +108,7 @@ const DEFAULTS = { export class Recurly extends Emitter { Adyen = Adyen; ApplePay = ApplePay; + GooglePay = GooglePay; BankRedirect = BankRedirect; coupon = coupon; Elements = Elements; diff --git a/lib/recurly/errors.js b/lib/recurly/errors.js index db4f682f1..94aa041d6 100644 --- a/lib/recurly/errors.js +++ b/lib/recurly/errors.js @@ -2,7 +2,45 @@ import { Reporter } from './reporter'; import squish from 'string-squish'; const BASE_URL = 'https://dev.recurly.com/docs/recurly-js-'; +const GOOGLE_PAY_ERRORS = [ + { + code: 'google-pay-not-available', + message: 'Google Pay is not available', + classification: 'environment' + }, + { + code: 'google-pay-config-missing', + message: c => `Missing Google Pay configuration option: '${c.opt}'`, + classification: 'merchant' + }, + { + code: 'google-pay-not-configured', + message: 'There are no Payment Methods enabled to support Google Pay', + classification: 'merchant' + }, + { + code: 'google-pay-config-invalid', + message: c => `Google Pay configuration option '${c.opt}' is not among your available options: ${c.set}. + Please refer to your site configuration if the available options is incorrect.`, + classification: 'merchant' + }, + { + code: 'google-pay-init-error', + message: c => { + let message = 'Google Pay did not initialize due to a fatal error'; + if (c.err) message += `: ${c.err.message}`; + return message; + }, + classification: 'internal' + }, + { + code: 'google-pay-payment-failure', + message: 'Google Pay could not get the Payment Data', + classification: 'internal' + }, +]; const ERRORS = [ + ...GOOGLE_PAY_ERRORS, { code: 'not-configured', message: 'Not configured. You must first call recurly.configure().', diff --git a/lib/recurly/google-pay/google-pay.js b/lib/recurly/google-pay/google-pay.js new file mode 100644 index 000000000..f00c72c00 --- /dev/null +++ b/lib/recurly/google-pay/google-pay.js @@ -0,0 +1,155 @@ +import Emitter from 'component-emitter'; +import { normalize } from '../../util/normalize'; +import { FIELDS as TOKEN_FIELDS } from '../token'; +import recurlyError from '../errors'; +import { payWithGoogle } from './pay-with-google'; + +const getRecurlyInputsFromHtmlForm = ({ $form, inputNames }) => $form ? normalize($form, inputNames).values : {}; + +const getBillingAddressFromGoogle = ({ paymentData }) => { + const googleBillingAddress = paymentData?.paymentMethodData?.info?.billingAddress || {}; + const { + name, + address1, + address2, + countryCode, + postalCode, + locality, + administrativeArea, + } = googleBillingAddress; + + const fullNameSplitted = (name || '').trim().split(' '); + const firstName = fullNameSplitted[0]; + const lastName = fullNameSplitted.slice(1).join(' '); + + return { + first_name: firstName, + last_name: lastName, + address1, + address2, + city: locality, + state: administrativeArea, + postal_code: postalCode, + country: countryCode, + }; +}; + +const createRecurlyToken = ({ recurly, paymentData }) => { + const userInputs = getRecurlyInputsFromHtmlForm({ $form: recurly.config.form, inputNames: TOKEN_FIELDS }); + const userBillingAddress = getBillingAddressFromGoogle({ paymentData }); + const userInputsOverrideBillingAddress = Object.keys(userInputs).some(k => k in userBillingAddress); + + const data = { + ...userInputs, + ...(!userInputsOverrideBillingAddress && userBillingAddress), + google_pay_token: paymentData?.paymentMethodData?.tokenizationData?.token, + }; + + return recurly.request.post({ route: '/google_pay/token', data }); +}; + +const validateGooglePayOptions = options => { + const requiredKeys = ['googleMerchantId', 'total', 'country', 'currency']; + requiredKeys.forEach(key => { + if (options[key] === undefined) { + throw recurlyError('google-pay-config-missing', { opt: key }); + } + }); + + return options; +}; + +const validateRecurlyMerchantInfo = ({ recurlyMerchantInfo }) => { + if (recurlyMerchantInfo.paymentMethods.length === 0) { + throw recurlyError('google-pay-not-configured'); + } + + return recurlyMerchantInfo; +}; + +const getGoogleInfoFromMerchantInfo = ({ recurlyMerchantInfo, options }) => { + const { siteMode, paymentMethods } = recurlyMerchantInfo; + const { + googleMerchantId, + googleBusinessName, + total, + country, + currency, + requireBillingAddress, + } = options; + + const environment = siteMode === 'production' ? 'PRODUCTION' : 'TEST'; + const googlePayConfig = { + apiVersion: 2, + apiVersionMinor: 0, + allowedPaymentMethods: paymentMethods.map(({ cardNetworks, authMethods, paymentGateway, direct }) => ({ + type: 'CARD', + parameters: { + allowedCardNetworks: cardNetworks, + allowedAuthMethods: authMethods, + ...(requireBillingAddress && { + billingAddressRequired: true, + billingAddressParameters: { + format: 'FULL', + }, + }), + }, + tokenizationSpecification: { + ...(paymentGateway && { + type: 'PAYMENT_GATEWAY', + parameters: paymentGateway, + }), + ...(direct && { + type: 'DIRECT', + parameters: direct, + }), + }, + })), + }; + const paymentDataRequest = { + ...googlePayConfig, + merchantInfo: { + merchantId: googleMerchantId, + merchantName: googleBusinessName, + }, + transactionInfo: { + totalPriceStatus: 'FINAL', // only when the price will nto change + totalPrice: total, + currencyCode: currency, + countryCode: country + }, + }; + + return { environment, googlePayConfig, paymentDataRequest }; +}; + +const getGooglePayInfo = ({ recurly, options }) => { + const { country, currency, gateway } = options; + const data = { country, currency, gateway }; + + return new Promise((resolve, reject) => { + try { + validateGooglePayOptions(options); + resolve(); + } catch (err) { + reject(err); + } + }).then(() => recurly.request.get({ route: '/google_pay/info', data })) + .then(recurlyMerchantInfo => validateRecurlyMerchantInfo({ recurlyMerchantInfo, options })) + .then(recurlyMerchantInfo => getGoogleInfoFromMerchantInfo({ recurlyMerchantInfo, options })); +}; + +const googlePay = (recurly, options) => { + const emitter = new Emitter(); + + getGooglePayInfo({ recurly, options }) + .then(googlePayInfo => payWithGoogle({ googlePayInfo, options })) + .then(({ $button, getPaymentData }) => emitter.emit('ready', $button) && getPaymentData()) + .then(paymentData => createRecurlyToken({ recurly, paymentData })) + .then(token => emitter.emit('token', token)) + .catch(err => emitter.emit('error', err)); + + return emitter; +}; + +export { googlePay }; diff --git a/lib/recurly/google-pay/index.js b/lib/recurly/google-pay/index.js new file mode 100644 index 000000000..5dfe521c7 --- /dev/null +++ b/lib/recurly/google-pay/index.js @@ -0,0 +1,13 @@ +import { googlePay } from './google-pay'; + +/** + * Returns a GooglePay instance. + * + * @param {Object} options + * @return {GooglePay} + */ +export function factory (options) { + const recurly = this; + + return googlePay(recurly, options); +} diff --git a/lib/recurly/google-pay/pay-with-google.js b/lib/recurly/google-pay/pay-with-google.js new file mode 100644 index 000000000..eb99df49a --- /dev/null +++ b/lib/recurly/google-pay/pay-with-google.js @@ -0,0 +1,50 @@ +import { loadLibs } from '../../util/dom'; +import recurlyError from '../errors'; + +const GOOGLE_PAY_LIB_URL = 'https://pay.google.com/gp/p/js/pay.js'; + +const payWithGoogle = ({ + googlePayInfo: { + environment, + googlePayConfig, + paymentDataRequest, + }, + options: { + buttonOptions, + }, +}) => { + let googlePayClient; + let deferredPaymentData; + + const paymentData = new Promise((resolve, reject) => { + deferredPaymentData = { resolve, reject }; + }); + + const onGooglePayButtonClicked = () => googlePayClient.loadPaymentData(paymentDataRequest) + .then(deferredPaymentData.resolve) + .catch(err => deferredPaymentData.reject(recurlyError('google-pay-payment-failure', { err }))); + + return loadLibs(GOOGLE_PAY_LIB_URL) + .then(() => { + googlePayClient = new window.google.payments.api.PaymentsClient({ environment }); + return googlePayClient.isReadyToPay(googlePayConfig); + }) + .catch(err => { + throw recurlyError('google-pay-init-error', { err }); + }) + .then(({ result: isReadyToPay }) => { + if (!isReadyToPay) { + throw recurlyError('google-pay-not-available'); + } + }) + .then(() => googlePayClient.createButton({ + ...buttonOptions, + onClick: onGooglePayButtonClicked + })) + .then($button => ({ + $button, + getPaymentData: () => paymentData, + })); +}; + +export { payWithGoogle }; diff --git a/lib/util/dom.js b/lib/util/dom.js index cdc71805b..b52acc8f9 100644 --- a/lib/util/dom.js +++ b/lib/util/dom.js @@ -4,6 +4,8 @@ var slug = require('to-slug-case'); var each = require('component-each'); +const Promise = require('promise'); +const loadScript = require('load-script'); /** * expose @@ -14,7 +16,8 @@ module.exports = { data: data, element: element, findNodeInParents: findNodeInParents, - value: value + value: value, + loadLibs, }; /** @@ -209,3 +212,10 @@ function createHiddenInput (attributes = {}) { return hidden; } + +function loadLibs (...libUrls) { + const promisify = Promise.denodeify; + const loadLib = promisify(loadScript); + + return Promise.all(libUrls.map(url => loadLib(url))); +} diff --git a/test/unit/google-pay/google-pay.test.js b/test/unit/google-pay/google-pay.test.js new file mode 100644 index 000000000..2c312b5c0 --- /dev/null +++ b/test/unit/google-pay/google-pay.test.js @@ -0,0 +1,509 @@ + +import assert from 'assert'; + +import recurlyError from '../../../lib/recurly/errors'; +import { initRecurly, apiTest, nextTick, assertDone, stubPromise, stubGooglePaymentAPI } from '../support/helpers'; +import { googlePay } from '../../../lib/recurly/google-pay/google-pay'; +import dom from '../../../lib/util/dom'; + +apiTest(requestMethod => describe.only('Google Pay', function () { + const cors = requestMethod === 'cors'; + + before(() => { + stubPromise(); + }); + + beforeEach(function () { + this.sandbox = sinon.createSandbox(); + + this.recurly = initRecurly({ cors }); + this.googlePayOpts = { + googleMerchantId: 'GOOGLE_MERCHANT_ID_123', + googleBusinessName: 'RECURLY', + total: '1', + country: 'US', + currency: 'USD', + requireBillingAddress: true, + gateway: { + stripe: { + publicKey: 'STRIPE_PUB_KEY_123', + }, + }, + }; + + this.paymentMethods = [ + { + cardNetworks: ['VISA'], + authMethods: ['PAM_ONLY'], + paymentGateway: 'PAYMENT_GATEWAY_PARAMETERS', + }, + { + cardNetworks: ['MASTERCARD'], + authMethods: ['PAM_ONLY'], + direct: 'DIRECT_PARAMETERS', + } + ]; + this.stubRequestOpts = { + info: Promise.resolve({ + siteMode: 'test', + paymentMethods: this.paymentMethods, + }), + token: Promise.resolve({ + id: 'TOKEN_123' + }) + }; + + this.stubGoogleAPIOpts = { dom }; + + this.stubRequestAndGoogleApi = () => { + this.cleanGoogleAPIStub = stubGooglePaymentAPI(this.stubGoogleAPIOpts); + this.sandbox.stub(this.recurly.request, 'get').resolves(this.stubRequestOpts.info); + this.sandbox.stub(this.recurly.request, 'post').resolves(this.stubRequestOpts.token); + }; + }); + + afterEach(function () { + this.sandbox.restore(); + + if (this.cleanGoogleAPIStub) { + this.cleanGoogleAPIStub(); + } + }); + + it('requests to Recurly the merchant Google Pay info with the initial options provided', function (done) { + this.stubRequestAndGoogleApi(); + googlePay(this.recurly, 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: { + stripe: { + publicKey: 'STRIPE_PUB_KEY_123', + }, + }, + }, + }); + })); + }); + + context('when missing a required option', function () { + const requiredKeys = ['googleMerchantId', 'total', '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 = googlePay(this.recurly, 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) { + googlePay(this.recurly, 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 = googlePay(this.recurly, 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 requesting to Recurly the merchant Google Pay info', function () { + beforeEach(function () { + this.stubRequestOpts.info = Promise.reject(recurlyError('api-error')); + this.stubRequestAndGoogleApi(); + }); + + it('emits an api-error', function (done) { + const result = googlePay(this.recurly, 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.'); + })); + }); + + it('do not initiate the pay-with-google', function (done) { + googlePay(this.recurly, this.googlePayOpts); + + nextTick(() => assertDone(done, () => { + assert.equal(window.google.payments.api.PaymentsClient.called, false); + })); + }); + + it('do not emit any token nor the on ready event', function (done) { + const result = googlePay(this.recurly, 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(); + }); + + it('emits a google-pay-not-configured error', function (done) { + const result = googlePay(this.recurly, 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'); + })); + }); + + it('do not initiate the pay-with-google', function (done) { + googlePay(this.recurly, this.googlePayOpts); + + nextTick(() => assertDone(done, () => { + assert.equal(window.google.payments.api.PaymentsClient.called, false); + })); + }); + + it('do not emit any token nor the on ready event', function (done) { + const result = googlePay(this.recurly, 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 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(); + googlePay(this.recurly, 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' }); + assert.deepEqual(window.google.payments.api.PaymentsClient.prototype.isReadyToPay.getCall(0).args[0], { + apiVersion: 2, + apiVersionMinor: 0, + allowedPaymentMethods: [ + { + type: 'CARD', + parameters: { + allowedCardNetworks: ['VISA'], + allowedAuthMethods: ['PAM_ONLY'], + billingAddressRequired: true, + billingAddressParameters: { + format: 'FULL', + }, + }, + tokenizationSpecification: { + type: 'PAYMENT_GATEWAY', + parameters: 'PAYMENT_GATEWAY_PARAMETERS', + }, + }, + { + type: 'CARD', + parameters: { + allowedCardNetworks: ['MASTERCARD'], + allowedAuthMethods: ['PAM_ONLY'], + billingAddressRequired: true, + billingAddressParameters: { + format: 'FULL', + }, + }, + tokenizationSpecification: { + type: 'DIRECT', + parameters: 'DIRECT_PARAMETERS', + }, + } + ], + }); + })); + }); + + context('when the site mode is production', function () { + beforeEach(function () { + this.stubRequestOpts.info = Promise.resolve({ + siteMode: 'production', + paymentMethods: this.paymentMethods, + }); + }); + + it('initiates the pay-with-google in PRODUCTION mode', function (done) { + this.stubRequestAndGoogleApi(); + googlePay(this.recurly, this.googlePayOpts); + + nextTick(() => assertDone(done, () => { + assert.deepEqual(window.google.payments.api.PaymentsClient.getCall(0).args[0], { environment: 'PRODUCTION' }); + })); + }); + }); + + context('when the site mode is any other than production', function () { + beforeEach(function () { + this.stubRequestOpts.info = Promise.resolve({ + siteMode: 'sandbox', + paymentMethods: this.paymentMethods, + }); + }); + + it('initiates the pay-with-google in TEST mode', function (done) { + this.stubRequestAndGoogleApi(); + googlePay(this.recurly, this.googlePayOpts); + + nextTick(() => assertDone(done, () => { + assert.deepEqual(window.google.payments.api.PaymentsClient.getCall(0).args[0], { environment: 'TEST' }); + })); + }); + }); + + context('when the billing address is not required', function () { + beforeEach(function () { + this.googlePayOpts.requireBillingAddress = false; + }); + + it('initiates the pay-with-google without the billing address requirement', function (done) { + this.stubRequestAndGoogleApi(); + googlePay(this.recurly, this.googlePayOpts); + + nextTick(() => assertDone(done, () => { + assert.deepEqual(window.google.payments.api.PaymentsClient.prototype.isReadyToPay.getCall(0).args[0], { + apiVersion: 2, + apiVersionMinor: 0, + allowedPaymentMethods: [ + { + type: 'CARD', + parameters: { + allowedCardNetworks: ['VISA'], + allowedAuthMethods: ['PAM_ONLY'], + }, + tokenizationSpecification: { + type: 'PAYMENT_GATEWAY', + parameters: 'PAYMENT_GATEWAY_PARAMETERS', + }, + }, + { + type: 'CARD', + parameters: { + allowedCardNetworks: ['MASTERCARD'], + allowedAuthMethods: ['PAM_ONLY'], + }, + tokenizationSpecification: { + type: 'DIRECT', + parameters: 'DIRECT_PARAMETERS', + }, + } + ], + }); + })); + }); + }); + + context('when cannot proceed with the pay-with-google', function () { + beforeEach(function () { + this.stubGoogleAPIOpts.isReadyToPay = Promise.resolve({ result: false }); + this.stubRequestAndGoogleApi(); + }); + + it('emits the same error the pay-with-google throws', function (done) { + const result = googlePay(this.recurly, 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 = googlePay(this.recurly, 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 pay-with-google success', function () { + it('emits the ready event with the google-pay button', function (done) { + this.stubRequestAndGoogleApi(); + const result = googlePay(this.recurly, this.googlePayOpts); + + result.on('ready', button => assertDone(done, () => { + assert.ok(button); + })); + }); + + context('when the google-pay button is clicked', function () { + beforeEach(function () { + this.clickGooglePayButton = (cb) => { + this.stubRequestAndGoogleApi(); + const result = googlePay(this.recurly, this.googlePayOpts); + + result.on('ready', button => { + button.click(); + cb(result); + }); + }; + }); + + it('requests the user Payment Data', function (done) { + this.clickGooglePayButton(() => { + nextTick(() => assertDone(done, () => { + assert.equal(window.google.payments.api.PaymentsClient.prototype.loadPaymentData.called, true); + })); + }); + }); + + context('when fails retrieving the user Payment Data', function () { + beforeEach(function () { + this.stubGoogleAPIOpts.loadPaymentData = Promise.reject(recurlyError('google-pay-payment-failure')); + }); + + 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'); + })); + }); + }); + + it('do not request any token to Recurly', function (done) { + this.clickGooglePayButton(() => { + nextTick(() => assertDone(done, () => { + assert.equal(this.recurly.request.post.called, false); + })); + }); + }); + }); + + 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(() => { + nextTick(() => 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: '', + google_pay_token: '{"id": "tok_123"}', + } + }); + })); + }); + }); + + context('when the user provide a
with custom billing address', function () { + beforeEach(function () { + this.recurly.config.form = { + first_name: 'Frank', + last_name: 'Isaac', + country: 'RF', + state: '', + city: '', + postal_code: '123', + address1: '', + address2: '', + }; + }); + + it('request to Recurly to create the token with the billing address from the ', function (done) { + this.clickGooglePayButton(() => { + nextTick(() => 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: 'Frank', + last_name: 'Isaac', + country: 'RF', + state: '', + city: '', + postal_code: '123', + address1: '', + address2: '', + google_pay_token: '{\"id\": \"tok_123\"}', + } + }); + })); + }); + }); + }); + + context('when Recurly fails creating the token', function () { + beforeEach(function () { + this.stubRequestOpts.token = Promise.reject(recurlyError('api-error')); + }); + + 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', + }); + })); + }); + }); + }); + }); + }); + }); + }); +})); diff --git a/test/unit/google-pay/pay-with-google.test.js b/test/unit/google-pay/pay-with-google.test.js new file mode 100644 index 000000000..7374c7a8b --- /dev/null +++ b/test/unit/google-pay/pay-with-google.test.js @@ -0,0 +1,225 @@ +import assert from 'assert'; +import dom from '../../../lib/util/dom'; + +import { nextTick, assertDone, stubPromise, stubGooglePaymentAPI } from '../support/helpers'; +import { payWithGoogle } from '../../../lib/recurly/google-pay/pay-with-google'; + +describe.only('Pay with Google', function () { + before(() => { + stubPromise(); + }); + + beforeEach(function () { + this.payWithGoogleOpts = { + googlePayInfo: { + environment: 'TEST', + googlePayConfig: 'GOOGLE PAY CONFIG', + paymentDataRequest: 'PAYMENT DATA REQUEST', + }, + options: { + buttonOptions: { + color: 'RED', + }, + }, + }; + + this.stubGoogleAPIOpts = { dom }; + this.stubGoogleApi = () => { + this.cleanGoogleAPIStub = stubGooglePaymentAPI(this.stubGoogleAPIOpts); + }; + }); + + afterEach(function () { + if (this.cleanGoogleAPIStub) { + this.cleanGoogleAPIStub(); + } + }); + + it('loads the Google Pay script https://pay.google.com/gp/p/js/pay.js', function (done) { + this.stubGoogleApi(); + + payWithGoogle(this.payWithGoogleOpts) + .finally(() => assertDone(done, () => { + assert.equal(dom.loadLibs.getCall(0).args[0], 'https://pay.google.com/gp/p/js/pay.js'); + })); + }); + + context('when failed loading the Google Pay script', function () { + beforeEach(function () { + this.stubGoogleAPIOpts.loadLibs = Promise.reject(new Error('Failed loading the Google Pay Lib')); + }); + + it('rejects with a google-pay-init-error', function (done) { + this.stubGoogleApi(); + + payWithGoogle(this.payWithGoogleOpts) + .catch(err => assertDone(done, () => { + assert.ok(err); + assert.equal(err.code, 'google-pay-init-error'); + assert.equal(err.message, 'Google Pay did not initialize due to a fatal error: Failed loading the Google Pay Lib'); + })); + }); + }); + + context('when success loading the Google Pay script', function () { + it('initializes the Google PaymentClient with the environment provided', function (done) { + this.stubGoogleApi(); + + payWithGoogle(this.payWithGoogleOpts) + .finally(() => assertDone(done, () => { + assert.deepEqual(window.google.payments.api.PaymentsClient.getCall(0).args[0], { environment: 'TEST' }); + })); + }); + + it('checks the Google Pay availability with the Google Pay Configuration provided', function (done) { + this.stubGoogleApi(); + + payWithGoogle(this.payWithGoogleOpts) + .finally(() => assertDone(done, () => { + assert.equal(window.google.payments.api.PaymentsClient.prototype.isReadyToPay.getCall(0).args[0], 'GOOGLE PAY CONFIG'); + })); + }); + + context('when fails checking the Google Pay availability', function () { + beforeEach(function () { + this.stubGoogleAPIOpts.isReadyToPay = Promise.reject(new Error('Failed to check Google Pay availability')); + }); + + it('rejects with a google-pay-init-error', function (done) { + this.stubGoogleApi(); + + payWithGoogle(this.payWithGoogleOpts) + .catch(err => assertDone(done, () => { + assert.ok(err); + assert.equal(err.code, 'google-pay-init-error'); + assert.equal(err.message, 'Google Pay did not initialize due to a fatal error: Failed to check Google Pay availability'); + })); + }); + }); + + context('when there are no availability to pay with Google', function () { + beforeEach(function () { + this.stubGoogleAPIOpts.isReadyToPay = Promise.resolve({ response: false }); + }); + + it('rejects with a google-pay-not-available error', function (done) { + this.stubGoogleApi(); + + payWithGoogle(this.payWithGoogleOpts) + .catch(err => assertDone(done, () => { + assert.ok(err); + assert.equal(err.code, 'google-pay-not-available'); + assert.equal(err.message, 'Google Pay is not available'); + })); + }); + }); + + context('when there are availability to pay with Google', function () { + it('resolves the Google Pay button and a function to get the user Payment Data', function (done) { + this.stubGoogleApi(); + + payWithGoogle(this.payWithGoogleOpts) + .then((result) => assertDone(done, () => { + assert.ok(result.$button); + assert.ok(result.getPaymentData); + })); + }); + + it('creates the Google Pay button with the button options provided', function (done) { + this.stubGoogleApi(); + + payWithGoogle(this.payWithGoogleOpts) + .finally(() => assertDone(done, () => { + assert.equal(window.google.payments.api.PaymentsClient.prototype.createButton.getCall(0).args[0].color, 'RED'); + })); + }); + + it('do not request the user Payment Data until the Google Pay button is clicked', function (done) { + this.stubGoogleApi(); + + payWithGoogle(this.payWithGoogleOpts) + .then(({ getPaymentData }) => getPaymentData()) + .finally(() => done(new Error('expected getPaymentData is not resolved'))); + + nextTick(() => assertDone(done, () => { + assert.equal(window.google.payments.api.PaymentsClient.prototype.loadPaymentData.called, false); + })); + }); + + context('when the Google Pay button is clicked', function () { + it('requests the user Payment Data with the PaymentDataRequest provided', function (done) { + this.stubGoogleApi(); + + payWithGoogle(this.payWithGoogleOpts) + .then(({ $button, getPaymentData }) => { + $button.click(); + return getPaymentData(); + }) + .finally(() => assertDone(done, () => { + assert.equal(window.google.payments.api.PaymentsClient.prototype.loadPaymentData.getCall(0).args[0], 'PAYMENT DATA REQUEST'); + })); + }); + + context('when fails requesting the user Payment Data', function () { + beforeEach(function () { + this.stubGoogleAPIOpts.loadPaymentData = Promise.reject(new Error('Failed to get the google payment data')); + }); + + it('rejects with a google-pay-payment-failure error', function (done) { + this.stubGoogleApi(); + + payWithGoogle(this.payWithGoogleOpts) + .then(({ $button, getPaymentData }) => { + $button.click(); + return getPaymentData(); + }) + .catch(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 success requesting the user Payment Data', function () { + it('resolves with the user Payment Data response', function (done) { + this.stubGoogleApi(); + + payWithGoogle(this.payWithGoogleOpts) + .then(({ $button, getPaymentData }) => { + $button.click(); + return getPaymentData(); + }) + .then(result => assertDone(done, () => { + assert.deepEqual(result, { + 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', + }, + }, + }, + }); + })); + }); + }); + }); + }); + }); +}); diff --git a/test/unit/support/helpers.js b/test/unit/support/helpers.js index 874618486..bd5b7c3d7 100644 --- a/test/unit/support/helpers.js +++ b/test/unit/support/helpers.js @@ -133,3 +133,70 @@ export function createNativeEvent (name) { event.initEvent(name, true, true); return event; } + +export function assertDone (done, assertCb) { + try { + assertCb(); + done(); + } catch (err) { + done(err); + } +} + +export function stubPromise() { + const isIE = !!document.documentMode; + + if (isIE) { + window.Promise = Promise; + } +} + +export function stubGooglePaymentAPI (options) { + options.loadLibs ||= Promise.resolve(true); + options.isReadyToPay ||= Promise.resolve({ result: true }); + options.loadPaymentData ||= Promise.resolve({ + 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', + }, + }, + }, + }); + const { dom, loadLibs, isReadyToPay, loadPaymentData } = options; + + const sandBox = sinon.createSandbox(); + + const PaymentsClient = sandBox.stub(); + PaymentsClient.prototype.isReadyToPay = sandBox.stub().resolves(isReadyToPay); + PaymentsClient.prototype.createButton = sandBox.stub().callsFake(opts => ({ click: () => opts.onClick() })); + PaymentsClient.prototype.loadPaymentData = sandBox.stub().resolves(loadPaymentData); + + sandBox.stub(dom, 'loadLibs') + .resolves(loadLibs.then(() => { + window.google = { + payments: { api: { PaymentsClient }} + }; + })); + + return () => { + sandBox.restore(); + delete window.google; + }; +} diff --git a/types/lib/google-pay.d.ts b/types/lib/google-pay.d.ts new file mode 100644 index 000000000..51134e195 --- /dev/null +++ b/types/lib/google-pay.d.ts @@ -0,0 +1,64 @@ +import { Emitter } from './emitter'; + +/** + * Options used to configure the Google Pay integration with Recurly. + */ +export type GooglePayOptions = { + /** + * Your ISO 3166 country code (ex: ‘US’). This is your country code as the merchant. + */ + country: string; + + /** + * ISO 4217 purchase currency (ex: ‘USD’). + */ + currency: string; + + /** + * Total cost to display in the Google Pay payment sheet. + */ + total: string; + + /** + * The Google merchant identifier issued after registration with the Google Pay and Wallet Console. + */ + googleMerchantId: string; + + /** + * The Google merchant business name registered with the Google Pay and Wallet Console. + */ + googleBusinessName?: string; + + /** + * The object to configure the Google Pay payment button. + * See https://developers.google.com/pay/api/web/reference/request-objects#ButtonOptions for options supported. + * + */ + buttonOptions?: { + [key: string]: any + }; + + /** + * Requires the user to accept providing the full billing address. + */ + requireBillingAddress?: boolean; + + /** + * Additional configuration object for each Payment Gateway integration required by Google Pay. + */ + gateway?: { + stripe?: { + publicKey: string + } + } +}; + +/** + * Google Pay events. + */ +export type GooglePayEvent = + | 'token' + | 'error' + | 'ready'; + +export type GooglePay = (options: GooglePayOptions) => Emitter;