Skip to content

Commit

Permalink
Merge pull request #733 from recurly/google-pay
Browse files Browse the repository at this point in the history
Adding support to google-pay
  • Loading branch information
epagerecurly authored Jul 8, 2022
2 parents e3ae63c + ecc3fff commit 047d61f
Show file tree
Hide file tree
Showing 10 changed files with 1,265 additions and 1 deletion.
2 changes: 2 additions & 0 deletions lib/recurly.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -107,6 +108,7 @@ const DEFAULTS = {
export class Recurly extends Emitter {
Adyen = Adyen;
ApplePay = ApplePay;
GooglePay = GooglePay;
BankRedirect = BankRedirect;
coupon = coupon;
Elements = Elements;
Expand Down
38 changes: 38 additions & 0 deletions lib/recurly/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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().',
Expand Down
171 changes: 171 additions & 0 deletions lib/recurly/google-pay/google-pay.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
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, $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),
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 {
environment: envOpt,
googleMerchantId,
googleBusinessName,
total,
country,
currency,
requireBillingAddress,
} = options;

const gatewayCodeSelected = paymentMethods.filter(({ gatewayCode }) => gatewayCode)[0]?.gatewayCode;
const environment = envOpt || (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 { gatewayCodeSelected, environment, googlePayConfig, paymentDataRequest };
};

const getGooglePayInfo = ({ recurly, options }) => {
const { country, currency, gatewayCode } = options;
const data = { country, currency, gateway_code: gatewayCode };

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();
const handleErr = err => emitter.emit('error', err);
let gatewayCodeSelected;

getGooglePayInfo({ recurly, options })
.then(googlePayInfo => {
gatewayCodeSelected = googlePayInfo.gatewayCodeSelected;
return payWithGoogle({ googlePayInfo, options });
})
.then(({ $button, paymentDataEmitter }) => emitter.emit('ready', $button) && paymentDataEmitter)
.catch(err => {
handleErr(err);
throw err;
})
.then(paymentDataEmitter => paymentDataEmitter
.on('payment-data', paymentData => createRecurlyToken({ recurly, paymentData, gatewayCodeSelected, $form: options.form })
.then(token => emitter.emit('token', token))
.catch(handleErr)
)
.on('error', handleErr)
);

return emitter;
};

export { googlePay };
13 changes: 13 additions & 0 deletions lib/recurly/google-pay/index.js
Original file line number Diff line number Diff line change
@@ -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);
}
51 changes: 51 additions & 0 deletions lib/recurly/google-pay/pay-with-google.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Emitter from 'component-emitter';
import { loadLibs } from '../../util/dom';
import recurlyError from '../errors';

const GOOGLE_PAY_LIB_URL = 'https://pay.google.com/gp/p/js/pay.js';

const loadGooglePayLib = () => Promise.resolve(
window.google?.payments?.api?.PaymentsClient || loadLibs(GOOGLE_PAY_LIB_URL)
);

const payWithGoogle = ({
googlePayInfo: {
environment,
googlePayConfig,
paymentDataRequest,
},
options: {
buttonOptions,
},
}) => {
let googlePayClient;
const paymentDataEmitter = new Emitter();

const onGooglePayButtonClicked = () => googlePayClient.loadPaymentData(paymentDataRequest)
.then(paymentData => paymentDataEmitter.emit('payment-data', paymentData))
.catch(err => paymentDataEmitter.emit('error', recurlyError('google-pay-payment-failure', { err })));

return loadGooglePayLib()
.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,
paymentDataEmitter,
}));
};

export { payWithGoogle };
12 changes: 11 additions & 1 deletion lib/util/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,7 +16,8 @@ module.exports = {
data: data,
element: element,
findNodeInParents: findNodeInParents,
value: value
value: value,
loadLibs,
};

/**
Expand Down Expand Up @@ -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)));
}
Loading

0 comments on commit 047d61f

Please sign in to comment.