Skip to content

Commit

Permalink
Merge pull request #794 from recurly/apple-pay-passthrough
Browse files Browse the repository at this point in the history
feat(Apple Pay): validate and support more ApplePayPaymentRequest features
  • Loading branch information
chrissrogers authored Mar 29, 2023
2 parents 939693d + 40c06c0 commit dd36259
Show file tree
Hide file tree
Showing 16 changed files with 463 additions and 151 deletions.
149 changes: 50 additions & 99 deletions lib/recurly/apple-pay/apple-pay.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import Emitter from 'component-emitter';
import errors from '../errors';
import { Pricing } from '../pricing';
import { FIELDS } from '../token';
import PricingPromise from '../pricing/promise';
import { normalize } from '../../util/normalize';
import decimalize from '../../util/decimalize';
import { FIELDS } from '../token';
import buildApplePayPaymentRequest from './util/build-apple-pay-payment-request';
import { lineItem } from './util/apple-pay-line-item';

const debug = require('debug')('recurly:apple-pay');

const APPLE_PAY_API_VERSION = 4;
const MINIMUM_SUPPORTED_VERSION = 4;
const APPLE_PAY_ADDRESS_MAP = {
first_name: 'givenName',
last_name: 'familyName',
Expand All @@ -22,22 +23,25 @@ const APPLE_PAY_ADDRESS_MAP = {

const I18N = {
subtotalLineItemLabel: 'Subtotal',
totalLineItemLabel: 'Total',
discountLineItemLabel: 'Discount',
taxLineItemLabel: 'Tax',
giftCardLineItemLabel: 'Gift card'
};

/**
* Initializes an Apple Pay session.
* Accepts all members of ApplePayPaymentRequest with the same name.
*
* @param {Object} options
* @param {Recurly} options.recurly
* @param {String} options.country
* @param {String} options.currency
* @param {String} options.label label to display to customers in the Apple Pay dialogue
* @param {String} options.total transaction total in format '1.00'
* @param {String|Object} [options.total] either in dollar format, '1.00', or an ApplePayLineItem object that represents the total for the payment. Optional and discarded if 'pricing' is supplied
* @param {String} [options.label] The short, localized description of the total charge. Deprecated, use 'i18n.totalLineItemLabel' if not using an ApplePayLineItem as the total
* @param {HTMLElement} [options.form] to provide additional customer data
* @param {Pricing} [options.pricing] to provide transaction total from Pricing
* @param {Pricing} [options.pricing] to provide line items and total from Pricing
* @param {Boolean} [options.enforceVersion] to ensure that the client supports the minimum version to support required fields
* @constructor
* @public
*/
Expand All @@ -47,20 +51,18 @@ export class ApplePay extends Emitter {
super();

this._ready = false;
this.config = {
i18n: I18N
};
this.config = {};
this.once('ready', () => this._ready = true);

// Detect whether Apple Pay is available
if (!(window.ApplePaySession && window.ApplePaySession.supportsVersion(APPLE_PAY_API_VERSION))) {
if (!(window.ApplePaySession && window.ApplePaySession.supportsVersion(MINIMUM_SUPPORTED_VERSION))) {
this.initError = this.error('apple-pay-not-supported');
} else if (!window.ApplePaySession.canMakePayments()) {
this.initError = this.error('apple-pay-not-available');
}

if (!this.initError) {
this.configure(options);
this.configure({ ...options });
}
}

Expand All @@ -71,28 +73,9 @@ export class ApplePay extends Emitter {
get session () {
if (this._session) return this._session;

debug('Creating new Apple Pay session');

let sessionOptions = {
countryCode: this.config.country,
currencyCode: this.config.currency,
supportedNetworks: this.config.supportedNetworks,
merchantCapabilities: this.config.merchantCapabilities,
requiredBillingContactFields: ['postalAddress'],
requiredShippingContactFields: this.config.requiredShippingContactFields,
total: this.totalLineItem,
};

if (this.config.applicationData) {
sessionOptions.applicationData = this.config.applicationData;
}

if (this.config.supportedCountries) {
sessionOptions.supportedCountries = this.config.supportedCountries;
}

let session = new window.ApplePaySession(APPLE_PAY_API_VERSION, sessionOptions);
debug('Creating new Apple Pay session', this._paymentRequest);

const session = new window.ApplePaySession(MINIMUM_SUPPORTED_VERSION, this._paymentRequest);
session.onvalidatemerchant = this.onValidateMerchant.bind(this);
session.onshippingcontactselected = this.onShippingContactSelected.bind(this);
session.onshippingmethodselected = this.onShippingMethodSelected.bind(this);
Expand All @@ -108,16 +91,20 @@ export class ApplePay extends Emitter {
* @private
*/
get lineItems () {
if (!this._ready) return [];

// Clone configured line items
return [].concat(this.config.lineItems);
return [].concat(this._paymentRequest.lineItems);
}

/**
* @return {Object} total cost line item
* @private
*/
get totalLineItem () {
return lineItem(this.config.label, this.config.total);
if (!this._ready) return {};

return this._paymentRequest.total;
}

/**
Expand Down Expand Up @@ -147,69 +134,44 @@ export class ApplePay extends Emitter {
* @private
*/
configure (options) {
if ('label' in options) this.config.label = options.label;
else return this.initError = this.error('apple-pay-config-missing', { opt: 'label' });

if ('form' in options) this.config.form = options.form;

// Initialize with no line items
this.config.lineItems = [];

if ('recurly' in options) this.recurly = options.recurly;
else return this.initError = this.error('apple-pay-factory-only');
if (options.recurly) {
this.recurly = options.recurly;
delete options.recurly;
} else return this.initError = this.error('apple-pay-factory-only');

if (options.form) {
this.config.form = options.form;
delete options.form;
}

if ('i18n' in options) Object.assign(this.config.i18n, options.i18n);
this.config.i18n = {
...I18N,
...(options.label && { totalLineItemLabel: options.label }),
...options.i18n,
};
delete options.label;
delete options.i18n;

if (options.pricing instanceof PricingPromise) {
this.config.pricing = options.pricing.pricing;
} else if (options.pricing instanceof Pricing) {
this.config.pricing = options.pricing;
} else if ('total' in options) {
this.config.total = options.total;
} else {
return this.initError = this.error('apple-pay-config-missing', { opt: 'total' });
}
delete options.pricing;

// If pricing is provided, attach change listeners
if (this.config.pricing) {
this.config.pricing.on('change', () => this.onPricingChange());
if (this.config.pricing.hasPrice) this.onPricingChange();
}

this.recurly.request.get({
route: '/apple_pay/info',
data: {
currency: options.currency,
country: options.country,
host: window.location.hostname,
},
done: this.applyRemoteConfig(options)
});
}

/**
* Assigns ApplePay configuration from site config
* @param {object} options
* @private
*/
applyRemoteConfig (options) {
return (err, info) => {
buildApplePayPaymentRequest(this, options, (err, paymentRequest) => {
if (err) return this.initError = this.error(err);

if ('countries' in info && ~info.countries.indexOf(options.country)) this.config.country = options.country;
else return this.initError = this.error('apple-pay-config-invalid', { opt: 'country', set: info.countries });

if ('currencies' in info && ~info.currencies.indexOf(options.currency)) this.config.currency = options.currency;
else return this.initError = this.error('apple-pay-config-invalid', { opt: 'currency', set: info.currencies });

if ('subdomain' in info) this.config.applicationData = btoa(info.subdomain);
this._paymentRequest = paymentRequest;

this.config.merchantCapabilities = info.merchantCapabilities || [];
this.config.supportedNetworks = info.supportedNetworks || [];
this.config.requiredShippingContactFields = options.requiredShippingContactFields || [];
// If pricing is provided, attach change listeners
if (this.config.pricing) {
this.onPricingChange();
this.config.pricing.on('change', () => this.onPricingChange());
}

this.emit('ready');
};
});
}

/**
Expand All @@ -232,11 +194,12 @@ export class ApplePay extends Emitter {
onPricingChange () {
const { pricing } = this.config;

let lineItems = this.config.lineItems = [];
this.config.total = pricing.totalNow;
this._paymentRequest.total = lineItem(this.config.i18n.totalLineItemLabel, pricing.totalNow);
this._paymentRequest.lineItems = [];

if (!pricing.hasPrice) return;
let taxAmount = pricing.price.now.taxes || pricing.price.now.tax;
const taxAmount = pricing.price.now.taxes || pricing.price.now.tax;
const lineItems = this._paymentRequest.lineItems;

lineItems.push(lineItem(this.config.i18n.subtotalLineItemLabel, pricing.subtotalPreDiscountNow));

Expand All @@ -251,8 +214,6 @@ export class ApplePay extends Emitter {
if (+pricing.price.now.giftCard) {
lineItems.push(lineItem(this.config.i18n.giftCardLineItemLabel, -pricing.price.now.giftCard));
}

this.config.lineItems = lineItems;
}

/**
Expand Down Expand Up @@ -422,13 +383,3 @@ export class ApplePay extends Emitter {
});
}
}

/**
* Builds an ApplePayLineItem
* @param {String} label
* @param {Number} amount
* @return {object}
*/
function lineItem (label = '', amount = 0) {
return { label, amount: decimalize(amount) };
}
20 changes: 20 additions & 0 deletions lib/recurly/apple-pay/util/apple-pay-line-item.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import decimalize from '../../../util/decimalize';

/**
* Builds an ApplePayLineItem
* @param {String} label
* @param {Number} amount
* @return {object}
*/
export function lineItem (label = '', amount = 0) {
return { label, amount: decimalize(amount) };
}

/**
* Determine if the val is a valid ApplePayLineItem
* @param {object} val
* @return {boolean}
*/
export function isLineItem (val) {
return typeof val === 'object' && val.label !== undefined && val.amount !== undefined;
}
104 changes: 104 additions & 0 deletions lib/recurly/apple-pay/util/build-apple-pay-payment-request.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import errors from '../../errors';
import filterSupportedNetworks from './filter-supported-networks';
import { lineItem, isLineItem } from './apple-pay-line-item';

const REQUIRED_SHIPPING_FIELDS_VERSION = 6;

function buildOrder (config, options, paymentRequest) {
if (!options.total) return errors('apple-pay-config-missing', { opt: 'total' });

const { total, lineItems = [] } = options;
['total', 'lineItems'].forEach(k => delete options[k]);

if (isLineItem(total)) {
paymentRequest.total = total;
} else if (typeof total === 'object') {
const missing = (total.label === undefined) ? 'label' : 'amount';
return errors('apple-pay-config-missing', { opt: `total.${missing}` });
} else {
paymentRequest.total = lineItem(config.i18n.totalLineItemLabel, total);
}

paymentRequest.lineItems = lineItems;
}

/**
* @callback requestCallback
* @param {Error} err
* @param {Object} ApplePayPaymentRequest
*/
/**
* Build the ApplePayPaymentRequest from the ApplePay options
*
* @param {Object} applePay instance of recurly.ApplePay
* @param {Object} options recurly.ApplePay options
* @param {requestCallback} cb callback that handles the payment request
* @private
*/
export default function buildApplePayPaymentRequest (applePay, options, cb) {
const { recurly, config } = applePay;

const { currency: currencyCode, country: countryCode } = options;
if (!currencyCode) return cb(errors('apple-pay-config-missing', { opt: 'currency' }));
if (!countryCode) return cb(errors('apple-pay-config-missing', { opt: 'country' }));
['currency', 'country'].forEach(k => delete options[k]);

let paymentRequest = {
currencyCode,
countryCode,
requiredBillingContactFields: ['postalAddress'],
};

// The order is handled by pricing if set
if (!config.pricing) {
const error = buildOrder(config, options, paymentRequest);
if (error) return cb(error);
}

if (options.enforceVersion &&
options.requiredShippingContactFields &&
!window.ApplePaySession.supportsVersion(REQUIRED_SHIPPING_FIELDS_VERSION)) return cb(errors('apple-pay-not-supported'));
delete options.enforceVersion;

paymentRequest = {
...options,
...paymentRequest,
};

recurly.request.get({
route: '/apple_pay/info',
data: {
currency: paymentRequest.currencyCode,
country: paymentRequest.countryCode,
host: window.location.hostname,
},
done: applyRemoteConfig(paymentRequest, cb)
});
}

/**
* Adds merchant site specific config to the payment request
* @param {object} options
* @param {requestCallback} cb callback that handles the payment request
* @private
*/
function applyRemoteConfig (paymentRequest, cb) {
return (err, info) => {
if (err) return cb(err);

if ('countries' in info && !~info.countries.indexOf(paymentRequest.countryCode)) {
return cb(errors('apple-pay-config-invalid', { opt: 'country', set: info.countries }));
}

if ('currencies' in info && !~info.currencies.indexOf(paymentRequest.currencyCode)) {
return cb(errors('apple-pay-config-invalid', { opt: 'currency', set: info.currencies }));
}

if ('subdomain' in info) paymentRequest.applicationData = btoa(info.subdomain);

paymentRequest.merchantCapabilities = info.merchantCapabilities || [];
paymentRequest.supportedNetworks = filterSupportedNetworks(info.supportedNetworks || []);

cb(null, paymentRequest);
};
}
Loading

0 comments on commit dd36259

Please sign in to comment.