Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Apple Pay): validate and support more ApplePayPaymentRequest features #794

Merged
merged 3 commits into from
Mar 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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