diff --git a/lib/recurly/errors.js b/lib/recurly/errors.js index a4fee2d01..6e23e5516 100644 --- a/lib/recurly/errors.js +++ b/lib/recurly/errors.js @@ -3,6 +3,11 @@ import squish from 'string-squish'; const BASE_URL = 'https://dev.recurly.com/docs/recurly-js-'; const GOOGLE_PAY_ERRORS = [ + { + code: 'google-pay-factory-only', + message: 'Google Pay must be initialized by calling recurly.GooglePay', + classification: 'merchant' + }, { code: 'google-pay-not-available', message: 'Google Pay is not available', diff --git a/lib/recurly/google-pay/google-pay.js b/lib/recurly/google-pay/google-pay.js index 39ef69c35..3929505ad 100644 --- a/lib/recurly/google-pay/google-pay.js +++ b/lib/recurly/google-pay/google-pay.js @@ -2,16 +2,14 @@ import Emitter from 'component-emitter'; import Promise from 'promise'; import { normalize } from '../../util/normalize'; import { FIELDS as TOKEN_FIELDS } from '../token'; -import recurlyError from '../errors'; +import errors from '../errors'; import { payWithGoogle } from './pay-with-google'; const debug = require('debug')('recurly:google-pay'); const API_VERSION = { apiVersion: 2, apiVersionMinor: 0 }; -const getRecurlyInputsFromHtmlForm = ({ $form, inputNames }) => $form ? normalize($form, inputNames, { parseCard: false }).values : {}; - -const getBillingAddressFromGoogle = ({ paymentData }) => { +const transformAddress = ({ paymentData }) => { const googleBillingAddress = paymentData?.paymentMethodData?.info?.billingAddress || {}; const { name, @@ -39,24 +37,9 @@ const getBillingAddressFromGoogle = ({ paymentData }) => { }; }; -const createRecurlyToken = ({ recurly, $form, paymentData, gatewayCodeSelected }) => { - const userInputs = getRecurlyInputsFromHtmlForm({ $form, inputNames: TOKEN_FIELDS }); - const userBillingAddress = getBillingAddressFromGoogle({ paymentData }); - const userInputsOverrideBillingAddress = Object.keys(userInputs).some(k => k in userBillingAddress); - - const data = { - gateway_code: gatewayCodeSelected, - ...userInputs, - ...(!userInputsOverrideBillingAddress && userBillingAddress), - paymentData, - }; - - return recurly.request.post({ route: '/google_pay/token', data }); -}; - -const validateRecurlyMerchantInfo = ({ recurlyMerchantInfo }) => { +const validateRecurlyMerchantInfo = (recurlyMerchantInfo) => { if (recurlyMerchantInfo.paymentMethods.length === 0) { - throw recurlyError('google-pay-not-configured'); + throw errors('google-pay-not-configured'); } return recurlyMerchantInfo; @@ -91,7 +74,7 @@ const buildIsReadyToPayRequest = (paymentMethods, { billingAddressRequired }) => }; }; -const getGoogleInfoFromMerchantInfo = ({ recurlyMerchantInfo, options }) => { +const getGoogleInfoFromMerchantInfo = (recurlyMerchantInfo, options) => { const { siteMode, paymentMethods } = recurlyMerchantInfo; const { environment: envOpt, } = options; const environment = envOpt || (siteMode === 'production' ? 'PRODUCTION' : 'TEST'); @@ -137,7 +120,8 @@ const getGoogleInfoFromMerchantInfo = ({ recurlyMerchantInfo, options }) => { }; }; -const buildPaymentDataRequest = ({ recurly, options }) => { +const buildPaymentDataRequest = (googlePay, options) => { + const { recurly } = googlePay; return new Promise((resolve, reject) => { const data = { gateway_code: options.gatewayCode, @@ -145,59 +129,115 @@ const buildPaymentDataRequest = ({ recurly, options }) => { country: options.country ?? options.paymentDataRequest?.transactionInfo.countryCode, }; - if (!data.currency) return reject(recurlyError('google-pay-config-missing', { opt: 'currency' })); - if (!data.country) return reject(recurlyError('google-pay-config-missing', { opt: 'country' })); + if (!data.currency) return reject(errors('google-pay-config-missing', { opt: 'currency' })); + if (!data.country) return reject(errors('google-pay-config-missing', { opt: 'country' })); resolve(data); }).then(data => recurly.request.get({ route: '/google_pay/info', data })) - .then(recurlyMerchantInfo => validateRecurlyMerchantInfo({ recurlyMerchantInfo, options })) - .then(recurlyMerchantInfo => getGoogleInfoFromMerchantInfo({ recurlyMerchantInfo, options })); + .then(recurlyMerchantInfo => validateRecurlyMerchantInfo(recurlyMerchantInfo)) + .then(recurlyMerchantInfo => getGoogleInfoFromMerchantInfo(recurlyMerchantInfo, options)); }; -const googlePay = (recurly, options) => { - const emitter = new Emitter(); - const handleErr = err => emitter.emit('error', err); - let gatewayCodeSelected; +export class GooglePay extends Emitter { + constructor (options) { + super(); + + this._ready = false; + this.config = {}; + this.options = options; + this.once('ready', () => this._ready = true); - const onPaymentAuthorized = (paymentData) => { - return createRecurlyToken({ recurly, paymentData, gatewayCodeSelected, $form: options.form }) + this.configure({ ...options }); + } + + /** + * Initialized state callback registry + * + * @param {Function} cb callback + * @public + */ + ready (cb) { + if (this._ready) cb(); + else this.once('ready', cb); + } + + /** + * Configures a new instance + * + * @param {Object} options + * @emit 'ready' + * @private + */ + configure (options) { + if (options.recurly) this.recurly = options.recurly; + else throw errors('google-pay-factory-only'); + + if (options.form) this.config.form = options.form; + if (options.callbacks) this.config.callbacks = options.callbacks; + + return buildPaymentDataRequest(this, options) + .then(({ gatewayCode, environment, isReadyToPayRequest, paymentDataRequest }) => { + this.config.gatewayCode = gatewayCode; + + return this.createButton({ + paymentOptions: { + environment, + merchantInfo: paymentDataRequest.merchantInfo, + paymentDataCallbacks: this.config.callbacks, + }, + isReadyToPayRequest, + paymentDataRequest, + buttonOptions: { + ...options.buttonOptions, + onClick: this.token.bind(this), + onError: (err) => this.emit('error', errors('google-pay-payment-failure', err)), + }, + }); + }) + .then(button => this.emit('ready', button)) + .catch(err => this.emit('error', err)); + } + + /** + * Creates the Google Pay button that will trigger the payment flow + * + * @public + */ + createButton (payWithGoogleRequest) { + return payWithGoogle(payWithGoogleRequest); + } + + token (paymentData) { + const data = this.mapPaymentData(paymentData); + debug('paymentData received', paymentData); + + return this.recurly.request.post({ route: '/google_pay/token', data }) .catch(err => { - handleErr(recurlyError('google-pay-payment-failure', err)); + debug('tokenization error', err); throw err; }) .then(token => { - emitter.emit('token', token, paymentData); + debug('Token received', token); + this.emit('token', token, paymentData); paymentData.recurlyToken = token; - debug('GooglePay.onPaymentAuthorized', paymentData); - emitter.emit('paymentAuthorized', paymentData); - return paymentData; - }); - }; + debug('onPaymentAuthorized', paymentData); + this.emit('paymentAuthorized', paymentData); - buildPaymentDataRequest({ recurly, options }) - .then(({ gatewayCode, environment, isReadyToPayRequest, paymentDataRequest }) => { - gatewayCodeSelected = gatewayCode; - - return payWithGoogle({ - paymentOptions: { - environment, - merchantInfo: paymentDataRequest.merchantInfo, - paymentDataCallbacks: options.callbacks, - }, - isReadyToPayRequest, - paymentDataRequest, - buttonOptions: { - ...options.buttonOptions, - onClick: onPaymentAuthorized, - onError: (err) => handleErr(recurlyError('google-pay-payment-failure', err)), - }, + return paymentData; }); - }) - .then(button => emitter.emit('ready', button)) - .catch(handleErr); + } - return emitter; -}; + mapPaymentData (paymentData) { + const formAddress = this.config.form ? normalize(this.config.form, TOKEN_FIELDS, { parseCard: false }).values : {}; + const googleBillingAddress = transformAddress({ paymentData }); + const useGoogleAddress = Object.keys(formAddress).some(k => k in googleBillingAddress); -export { googlePay }; + return { + gateway_code: this.config.gatewayCode, + ...formAddress, + ...(!useGoogleAddress && googleBillingAddress), + paymentData, + }; + } +} diff --git a/lib/recurly/google-pay/index.js b/lib/recurly/google-pay/index.js index 5dfe521c7..66af41d9a 100644 --- a/lib/recurly/google-pay/index.js +++ b/lib/recurly/google-pay/index.js @@ -1,4 +1,4 @@ -import { googlePay } from './google-pay'; +import { GooglePay } from './google-pay'; /** * Returns a GooglePay instance. @@ -7,7 +7,7 @@ import { googlePay } from './google-pay'; * @return {GooglePay} */ export function factory (options) { - const recurly = this; + const factoryClass = GooglePay; - return googlePay(recurly, options); + return new factoryClass(Object.assign({}, options, { recurly: this })); } diff --git a/lib/recurly/google-pay/pay-with-google.js b/lib/recurly/google-pay/pay-with-google.js index 54bf4475c..33ec1e2e5 100644 --- a/lib/recurly/google-pay/pay-with-google.js +++ b/lib/recurly/google-pay/pay-with-google.js @@ -29,7 +29,7 @@ function createButton ( }, }); - debug('GooglePay.createButton', paymentDataRequest, buttonOptions); + debug('createButton', paymentDataRequest, buttonOptions); return googlePayClient.createButton({ ...buttonOptions, allowedPaymentMethods, @@ -68,10 +68,10 @@ const payWithGoogle = ({ paymentOptions, isReadyToPayRequest, paymentDataRequest let googlePayClient; return loadGooglePayLib() .then(() => { - debug('GooglePay.newPaymentsClient', paymentOptions); + debug('newPaymentsClient', paymentOptions); googlePayClient = new window.google.payments.api.PaymentsClient(paymentOptions); - debug('GooglePay.isReadyToPay', isReadyToPayRequest); + debug('isReadyToPay', isReadyToPayRequest); return googlePayClient.isReadyToPay(isReadyToPayRequest); }) .catch(err => { diff --git a/test/e2e/support/helpers.js b/test/e2e/support/helpers.js index 1b4707164..3d86f842c 100644 --- a/test/e2e/support/helpers.js +++ b/test/e2e/support/helpers.js @@ -168,8 +168,6 @@ function init ({ fixture = '', opts = {} } = {}) { * @return {Promise} */ async function configureRecurly (opts = {}) { - console.log('configureRecurly', opts); - return await browser.executeAsync(function (opts, done) { recurly.configure(opts); recurly.ready(function () { diff --git a/test/unit/apple-pay.test.js b/test/unit/apple-pay.test.js index 38414a2cd..5291ecdd8 100644 --- a/test/unit/apple-pay.test.js +++ b/test/unit/apple-pay.test.js @@ -7,7 +7,6 @@ import Emitter from 'component-emitter'; import Promise from 'promise'; import { initRecurly, nextTick } from './support/helpers'; import BraintreeLoader from '../../lib/util/braintree-loader'; -import { ApplePayBraintree } from '../../lib/recurly/apple-pay/apple-pay.braintree'; import filterSupportedNetworks from '../../lib/recurly/apple-pay/util/filter-supported-networks'; const infoFixture = require('@recurly/public-api-test-server/fixtures/apple_pay/info'); diff --git a/test/unit/google-pay/google-pay.test.js b/test/unit/google-pay/google-pay.test.js index aee9d9b13..794bd8c9e 100644 --- a/test/unit/google-pay/google-pay.test.js +++ b/test/unit/google-pay/google-pay.test.js @@ -2,10 +2,9 @@ import assert from 'assert'; import recurlyError from '../../../lib/recurly/errors'; import { initRecurly, nextTick, assertDone, stubGooglePaymentAPI } from '../support/helpers'; -import { googlePay } from '../../../lib/recurly/google-pay/google-pay'; import dom from '../../../lib/util/dom'; -describe(`Google Pay`, function () { +describe('Google Pay', function () { beforeEach(function () { this.sandbox = sinon.createSandbox(); @@ -63,7 +62,7 @@ describe(`Google Pay`, function () { it('requests to Recurly the merchant Google Pay info with the initial options provided', function (done) { this.stubRequestAndGoogleApi(); - googlePay(this.recurly, this.googlePayOpts); + this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { assert.equal(this.recurly.request.get.called, true); @@ -88,7 +87,7 @@ describe(`Google Pay`, function () { }); it('emits a google-pay-config-missing error', function (done) { - const result = googlePay(this.recurly, this.googlePayOpts); + const result = this.recurly.GooglePay(this.googlePayOpts); result.on('error', (err) => assertDone(done, () => { assert.ok(err); @@ -98,7 +97,7 @@ describe(`Google Pay`, function () { }); it('do not initiate the pay-with-google nor requests to Recurly the merchant Google Pay info', function (done) { - googlePay(this.recurly, this.googlePayOpts); + this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { assert.equal(this.recurly.request.get.called, false); @@ -107,7 +106,7 @@ describe(`Google Pay`, function () { }); it('do not emit any token nor the on ready event', function (done) { - const result = googlePay(this.recurly, this.googlePayOpts); + 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'))); @@ -124,7 +123,7 @@ describe(`Google Pay`, function () { }); it('emits an api-error', function (done) { - const result = googlePay(this.recurly, this.googlePayOpts); + const result = this.recurly.GooglePay(this.googlePayOpts); result.on('error', (err) => assertDone(done, () => { assert.ok(err); @@ -134,7 +133,7 @@ describe(`Google Pay`, function () { }); it('do not initiate the pay-with-google', function (done) { - googlePay(this.recurly, this.googlePayOpts); + this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { assert.equal(window.google.payments.api.PaymentsClient.called, false); @@ -142,7 +141,7 @@ describe(`Google Pay`, function () { }); it('do not emit any token nor the on ready event', function (done) { - const result = googlePay(this.recurly, this.googlePayOpts); + 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'))); @@ -160,7 +159,7 @@ describe(`Google Pay`, function () { }); it('emits a google-pay-not-configured error', function (done) { - const result = googlePay(this.recurly, this.googlePayOpts); + const result = this.recurly.GooglePay(this.googlePayOpts); result.on('error', (err) => assertDone(done, () => { assert.ok(err); @@ -170,7 +169,7 @@ describe(`Google Pay`, function () { }); it('do not initiate the pay-with-google', function (done) { - googlePay(this.recurly, this.googlePayOpts); + this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { assert.equal(window.google.payments.api.PaymentsClient.called, false); @@ -178,7 +177,7 @@ describe(`Google Pay`, function () { }); it('do not emit any token nor the on ready event', function (done) { - const result = googlePay(this.recurly, this.googlePayOpts); + 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'))); @@ -189,7 +188,7 @@ describe(`Google Pay`, function () { 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); + this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { assert.equal(window.google.payments.api.PaymentsClient.called, true); @@ -251,7 +250,7 @@ describe(`Google Pay`, function () { it('initiates the pay-with-google in the specified environment', function (done) { this.stubRequestAndGoogleApi(); - googlePay(this.recurly, this.googlePayOpts); + this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { assert.deepEqual(window.google.payments.api.PaymentsClient.getCall(0).args[0].environment, 'TEST'); @@ -270,7 +269,7 @@ describe(`Google Pay`, function () { it('initiates the pay-with-google in PRODUCTION mode', function (done) { this.stubRequestAndGoogleApi(); - googlePay(this.recurly, this.googlePayOpts); + this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { assert.deepEqual(window.google.payments.api.PaymentsClient.getCall(0).args[0].environment, 'PRODUCTION'); @@ -289,7 +288,7 @@ describe(`Google Pay`, function () { it('initiates the pay-with-google in the specified environment', function (done) { this.stubRequestAndGoogleApi(); - googlePay(this.recurly, this.googlePayOpts); + this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { assert.deepEqual(window.google.payments.api.PaymentsClient.getCall(0).args[0].environment, 'PRODUCTION'); @@ -308,7 +307,7 @@ describe(`Google Pay`, function () { it('initiates the pay-with-google in TEST mode', function (done) { this.stubRequestAndGoogleApi(); - googlePay(this.recurly, this.googlePayOpts); + this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { assert.deepEqual(window.google.payments.api.PaymentsClient.getCall(0).args[0].environment, 'TEST'); @@ -323,7 +322,7 @@ describe(`Google Pay`, function () { it('initiates the pay-with-google without the billing address requirement', function (done) { this.stubRequestAndGoogleApi(); - googlePay(this.recurly, this.googlePayOpts); + this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { const { allowedPaymentMethods: [{ parameters }] } = window.google.payments.api.PaymentsClient.prototype.isReadyToPay.getCall(0).args[0]; @@ -341,7 +340,7 @@ describe(`Google Pay`, function () { it('initiates the pay-with-google without the billing address requirement', function (done) { this.stubRequestAndGoogleApi(); - googlePay(this.recurly, this.googlePayOpts); + this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { const { allowedPaymentMethods: [{ parameters }] } = window.google.payments.api.PaymentsClient.prototype.isReadyToPay.getCall(0).args[0]; @@ -364,7 +363,7 @@ describe(`Google Pay`, function () { totalPrice: '1', }; - googlePay(this.recurly, { + this.recurly.GooglePay({ ...this.googlePayOpts, billingAddressRequired: false, paymentDataRequest: { @@ -400,7 +399,7 @@ describe(`Google Pay`, function () { totalPrice: '1', }; - googlePay(this.recurly, { + this.recurly.GooglePay({ billingAddressRequired: false, paymentDataRequest: { merchantInfo, @@ -430,7 +429,7 @@ describe(`Google Pay`, function () { it('handles the shipping address intent if onPaymentDataChanged is provided and requiring shipping address', function (done) { this.stubRequestAndGoogleApi(); const callbacks = { onPaymentDataChanged: () => {} }; - googlePay(this.recurly, { + this.recurly.GooglePay({ ...this.googlePayOpts, callbacks, paymentDataRequest: { @@ -448,7 +447,7 @@ describe(`Google Pay`, function () { it('handles the shipping option intent if onPaymentDataChanged is provided and requiring shipping option', function (done) { this.stubRequestAndGoogleApi(); const callbacks = { onPaymentDataChanged: () => {} }; - googlePay(this.recurly, { + this.recurly.GooglePay({ ...this.googlePayOpts, callbacks, paymentDataRequest: { @@ -478,7 +477,7 @@ describe(`Google Pay`, function () { it('handles the payment authorized intent', function (done) { const callbacks = { onPaymentAuthorized: () => {} }; - googlePay(this.recurly, { + this.recurly.GooglePay({ ...this.googlePayOpts, callbacks, }); @@ -492,7 +491,7 @@ describe(`Google Pay`, function () { it('is called after the button is clicked with the paymentData and token', function (done) { let paymentData; - const emitter = googlePay(this.recurly, { + const emitter = this.recurly.GooglePay({ ...this.googlePayOpts, callbacks: { onPaymentAuthorized: (pd) => paymentData = pd }, }); @@ -509,7 +508,7 @@ describe(`Google Pay`, function () { this.sandbox.stub(this.recurly.request, 'post').rejects('boom'); const onPaymentAuthorized = this.sandbox.stub(); - const emitter = googlePay(this.recurly, { + const emitter = this.recurly.GooglePay({ ...this.googlePayOpts, callbacks: { onPaymentAuthorized }, }); @@ -531,7 +530,7 @@ describe(`Google Pay`, function () { message: 'Cannot pay with payment credentials', intent: 'PAYMENT_AUTHORIZATION', }; - const emitter = googlePay(this.recurly, { + const emitter = this.recurly.GooglePay({ ...this.googlePayOpts, callbacks: { onPaymentAuthorized: () => ({ error }) }, }); @@ -552,7 +551,7 @@ describe(`Google Pay`, function () { }); it('emits the same error the pay-with-google throws', function (done) { - const result = googlePay(this.recurly, this.googlePayOpts); + const result = this.recurly.GooglePay(this.googlePayOpts); result.on('error', (err) => assertDone(done, () => { assert.ok(err); @@ -562,7 +561,7 @@ describe(`Google Pay`, function () { }); it('do not emit any token nor the on ready event', function (done) { - const result = googlePay(this.recurly, this.googlePayOpts); + 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'))); @@ -578,7 +577,7 @@ describe(`Google Pay`, function () { }); it('initiates pay-with-google with the expected Google Pay Configuration', function (done) { - googlePay(this.recurly, this.googlePayOpts); + this.recurly.GooglePay(this.googlePayOpts); nextTick(() => assertDone(done, () => { assert.equal(window.google.payments.api.PaymentsClient.called, true); @@ -588,7 +587,7 @@ describe(`Google Pay`, function () { }); it('emits the same error the pay-with-google throws', function (done) { - const result = googlePay(this.recurly, this.googlePayOpts); + const result = this.recurly.GooglePay(this.googlePayOpts); result.on('error', (err) => assertDone(done, () => { assert.ok(err); @@ -598,7 +597,7 @@ describe(`Google Pay`, function () { }); it('do not emit any token nor the on ready event', function (done) { - const result = googlePay(this.recurly, this.googlePayOpts); + 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'))); @@ -610,7 +609,7 @@ describe(`Google Pay`, 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 = googlePay(this.recurly, this.googlePayOpts); + const result = this.recurly.GooglePay(this.googlePayOpts); result.on('ready', button => assertDone(done, () => { assert.ok(button); @@ -621,7 +620,7 @@ describe(`Google Pay`, function () { beforeEach(function () { this.clickGooglePayButton = (cb) => { this.stubRequestAndGoogleApi(); - const result = googlePay(this.recurly, this.googlePayOpts); + const result = this.recurly.GooglePay(this.googlePayOpts); result.on('ready', button => { cb(result); diff --git a/test/unit/paypal/strategy/complete.test.js b/test/unit/paypal/strategy/complete.test.js index b2c9abce0..9c13b4e50 100644 --- a/test/unit/paypal/strategy/complete.test.js +++ b/test/unit/paypal/strategy/complete.test.js @@ -20,7 +20,6 @@ describe('CompleteStrategy', function () { it('opens iframe with PayPal Complete start path', function () { this.sandbox.spy(this.recurly, 'Frame'); this.paypal.start(); - console.log({ calls: this.recurly.Frame.getCalls() }); assert(this.recurly.Frame.calledWith({ path: '/paypal_complete/start' })); }); });