Skip to content

Commit

Permalink
Merge pull request #426 from recurly/apple-pay-zero-amount-auth
Browse files Browse the repository at this point in the history
Apple pay zero amount authorizations
  • Loading branch information
snodgrass23 authored Feb 15, 2018
2 parents c646e0d + de61551 commit 74cbea1
Show file tree
Hide file tree
Showing 5 changed files with 263 additions and 69 deletions.
1 change: 1 addition & 0 deletions karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ var staticConfig = {
autoWatch: true,
browsers: [
'PhantomJS'
// 'ChromeDebug'
// 'IE11 - Win7'
],
singleRun: true,
Expand Down
160 changes: 118 additions & 42 deletions lib/recurly/apple-pay.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Emitter from 'component-emitter';
import errors from '../errors';
import {Pricing} from './pricing';
import {normalize} from '../util/normalize';
import decimalize from '../util/decimalize';
import {FIELDS} from './token';

const debug = require('debug')('recurly:apple-pay');
Expand All @@ -16,7 +17,15 @@ const APPLE_PAY_ADDRESS_MAP = {
state: 'administrativeArea',
postal_code: 'postalCode',
country: 'countryCode'
}
};

const I18N = {
authorizationLineItemLabel: 'Card Authorization (Temporary)',
subtotalLineItemLabel: 'Subtotal',
discountLineItemLabel: 'Discount',
taxLineItemLabel: 'Tax',
giftCardLineItemLabel: 'Gift card'
};

/**
* Instantiation factory
Expand Down Expand Up @@ -48,7 +57,9 @@ class ApplePay extends Emitter {
super();

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

// Detect whether Apple Pay is available
Expand All @@ -72,13 +83,15 @@ class ApplePay extends Emitter {

debug('Creating new Apple Pay session');

this.addAuthorizationLineItem();

let session = new global.ApplePaySession(APPLE_PAY_API_VERSION, {
countryCode: this.config.country,
currencyCode: this.config.currency,
supportedNetworks: this.config.supportedNetworks,
merchantCapabilities: this.config.merchantCapabilities,
requiredBillingContactFields: ['postalAddress'],
total: { label: this.config.label, amount: this.config.total }
total: this.totalLineItem
});

session.onvalidatemerchant = this.onValidateMerchant.bind(this);
Expand All @@ -103,8 +116,25 @@ class ApplePay extends Emitter {
* @return {Object} total cost line item
* @private
*/
get total () {
return { type: 'final', label: this.config.label, amount: this.config.total };
get totalLineItem () {
return lineItem(this.config.label, this.config.total);
}

/**
* @return {Object} total cost line item indicating a finalized line item
* @private
*/
get finalTotalLineItem () {
return Object.assign({}, this.totalLineItem, { type: 'final' });
}

/**
* Used when the total price is zero, to circumvent Apple Pay
* zero amount authorization limitation
* @return {Object} card authorization line item, $1.00
*/
get authorizationLineItem () {
return lineItem(this.config.i18n.authorizationLineItemLabel, 1.00);
}

/**
Expand Down Expand Up @@ -134,21 +164,23 @@ class ApplePay extends Emitter {
// 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 ('i18n' in options) Object.assign(this.config.i18n, options.i18n);

// Listen for pricing changes to update totals and currency
const { pricing } = options;
if (pricing instanceof Pricing) {
this.config.pricing = pricing;
pricing.on('change', price => this.updatePriceFromPricing());
if (pricing.hasPrice) this.updatePriceFromPricing();
pricing.on('change', price => this.onPricingChange());
if (pricing.hasPrice) this.onPricingChange();
} else if ('total' in options) {
this.config.total = options.total;
} else {
return this.initError = this.error('apple-pay-config-missing', { opt: 'total' });
}

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

// Retrieve remote configuration
this.recurly.request('get', '/apple_pay/info', (err, info) => {
if (err) return this.initError = this.error(err);
Expand All @@ -166,32 +198,6 @@ class ApplePay extends Emitter {
});
}

/**
* Maps data from the Apple Pay token into the inputs
* object that is sent to RA for tokenization
*
* @public
*/
mapPaymentData (inputs, data) {
inputs.paymentData = data.token.paymentData;
inputs.paymentMethod = data.token.paymentMethod;

if (!data.billingContact) return;
if (FIELDS.some(field => inputs[field])) return;

FIELDS.forEach(field => {
if (!APPLE_PAY_ADDRESS_MAP[field]) return;

let tokenData = data.billingContact[APPLE_PAY_ADDRESS_MAP[field]];

// address lines are an array from Apple Pay
if (field === 'address1') tokenData = tokenData[0];
else if (field === 'address2') tokenData = tokenData[1];

inputs[field] = tokenData;
});
}

/**
* Begins Apple Pay transaction
*
Expand All @@ -203,15 +209,48 @@ class ApplePay extends Emitter {
this.session.begin();
}

/**
* Conditionally adds an authorization line item in order to circumvent Apple Pay
* zero-amount authorization limitation
*
* @private
*/
addAuthorizationLineItem () {
if (parseFloat(this.config.total) > 0) return;
this.config.lineItems.push(this.authorizationLineItem);
this.config.total = this.authorizationLineItem.amount;
}

/**
* Updates line items and total price on pricing module changes
*
* @param {Object} price Pricing.price
* @private
*/
updatePriceFromPricing () {
this.config.lineItems = [];
this.config.total = this.config.pricing.totalNow;
onPricingChange () {
const { pricing } = this.config;

let lineItems = this.config.lineItems = [];
this.config.total = pricing.totalNow;

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

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

if (+pricing.price.now.discount) {
lineItems.push(lineItem(this.config.i18n.discountLineItemLabel, -pricing.price.now.discount));
}

if (+taxAmount) {
lineItems.push(lineItem(this.config.i18n.taxLineItemLabel, taxAmount));
}

if (+pricing.price.now.giftCard) {
lineItems.push(lineItem(this.config.i18n.giftCardLineItemLabel, -pricing.price.now.giftCard));
}

this.config.lineItems = lineItems;
}

/**
Expand Down Expand Up @@ -240,7 +279,7 @@ class ApplePay extends Emitter {
*/
onPaymentMethodSelected (event) {
debug('Payment method selected', event);
this.session.completePaymentMethodSelection(this.total, this.lineItems);
this.session.completePaymentMethodSelection(this.finalTotalLineItem, this.lineItems);
}

/**
Expand All @@ -252,7 +291,7 @@ class ApplePay extends Emitter {
onShippingContactSelected (event) {
const status = this.session.STATUS_SUCCESS;
const newShippingMethods = [];
this.session.completeShippingContactSelection(status, newShippingMethods, this.total, this.lineItems);
this.session.completeShippingContactSelection(status, newShippingMethods, this.finalTotalLineItem, this.lineItems);
}

/**
Expand All @@ -262,7 +301,7 @@ class ApplePay extends Emitter {
* @private
*/
onShippingMethodSelected (event) {
this.session.completeShippingMethodSelection(this.total, this.lineItems);
this.session.completeShippingMethodSelection(this.finalTotalLineItem, this.lineItems);
}

/**
Expand Down Expand Up @@ -323,4 +362,41 @@ class ApplePay extends Emitter {
this.emit('error', err);
return err;
}


/**
* Maps data from the Apple Pay token into the inputs
* object that is sent to RA for tokenization
*
* @private
*/
mapPaymentData (inputs, data) {
inputs.paymentData = data.token.paymentData;
inputs.paymentMethod = data.token.paymentMethod;

if (!data.billingContact) return;
if (FIELDS.some(field => inputs[field])) return;

FIELDS.forEach(field => {
if (!APPLE_PAY_ADDRESS_MAP[field]) return;

let tokenData = data.billingContact[APPLE_PAY_ADDRESS_MAP[field]];

// address lines are an array from Apple Pay
if (field === 'address1') tokenData = tokenData[0];
else if (field === 'address2') tokenData = tokenData[1];

inputs[field] = tokenData;
});
}
}

/**
* Builds an ApplePayLineItem
* @param {String} label
* @param {Number} amount
* @return {object}
*/
function lineItem (label = '', amount = 0) {
return { label, amount: decimalize(amount) };
}
5 changes: 5 additions & 0 deletions lib/recurly/pricing/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export class Pricing extends Emitter {
return decimalize(this.hasPrice ? this.price.now.total : 0);
}

get subtotalPreDiscountNow () {
let subtotalPreDiscountNow = parseFloat(this.price.now.subtotal) + parseFloat(this.price.now.discount);
return decimalize(this.hasPrice ? subtotalPreDiscountNow : 0)
}

get currencyCode () {
return this.items.currency || '';
}
Expand Down
Loading

0 comments on commit 74cbea1

Please sign in to comment.