Skip to content

Commit

Permalink
Merge pull request #336 from recurly/paypal-fallback
Browse files Browse the repository at this point in the history
Adds BraintreePayPal failure fallback
  • Loading branch information
snodgrass23 authored Mar 23, 2017
2 parents 0323925 + e2dddd3 commit 76b34d1
Show file tree
Hide file tree
Showing 12 changed files with 280 additions and 58 deletions.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ $ make test-browser
**to run tests on Sauce Labs**

```bash
$ SAUCE_USERNAME=user SAUCE_ACCESS_KEY=key make test-sauce
$ SAUCE_USERNAME=user SAUCE_ACCESS_KEY=key make test-ci
```

## Coding Standards
Expand Down
1 change: 1 addition & 0 deletions karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ var staticConfig = {
}
},
client: {
captureConsole: true,
mocha: {
timeout : 20000, // 20 seconds
grep: ''
Expand Down
6 changes: 5 additions & 1 deletion lib/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ errors.add('apple-pay-config-invalid', {
});

errors.add('apple-pay-factory-only', {
message: 'Apple Pay must be initialized by calling recurly.applePay'
message: 'Apple Pay must be initialized by calling recurly.ApplePay'
});

errors.add('apple-pay-init-error', {
Expand All @@ -224,6 +224,10 @@ errors.add('apple-pay-payment-failure', {
message: 'Apply Pay could not charge the customer'
});

errors.add('paypal-factory-only', {
message: 'PayPal must be initialized by calling recurly.PayPal'
});

errors.add('paypal-config-missing', {
message: c => `Missing PayPal configuration option: '${c.opt}'`
});
Expand Down
3 changes: 2 additions & 1 deletion lib/recurly.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import errors from './errors';
import version from './version';
import bankAccount from './recurly/bank-account';
import {factory as ApplePay} from './recurly/apple-pay';
import {factory as PayPal, deprecatedPaypal} from './recurly/paypal';
import {factory as PayPal} from './recurly/paypal/factory';
import {deprecatedPaypal} from './recurly/paypal';
import {Bus} from './recurly/bus';
import {Fraud} from './recurly/fraud';
import {HostedFields, FIELD_TYPES} from './recurly/hosted-fields';
Expand Down
103 changes: 63 additions & 40 deletions lib/recurly/paypal/braintree.js
Original file line number Diff line number Diff line change
@@ -1,63 +1,80 @@
import loadScript from 'load-script';
import Emitter from 'component-emitter';
import omit from 'lodash.omit';
import after from 'lodash.after';
import errors from '../../errors';
import loadScript from 'load-script';
import {PayPal} from './';

const debug = require('debug')('recurly:paypal:braintree');

const BRAINTREE_CLIENT_VERSION = '3.8.0';
export const BRAINTREE_CLIENT_VERSION = '3.11.0';

/**
* Braintree-specific PayPal handler
*
* TODO: make inherit from PayPal instead of Emitter to consolidate error handler and init
*/

export class BraintreePayPal extends Emitter {
constructor (options) {
super();
this.ready = false;
this.config = {};
this.configure(options);
}

export class BraintreePayPal extends PayPal {
configure (options) {
super.configure(options);
if (!options.braintree || !options.braintree.clientAuthorization) {
throw this.error('paypal-config-missing', { opt: 'braintree.clientAuthorization'})
return this.error('paypal-config-missing', { opt: 'braintree.clientAuthorization'})
}
this.config.clientAuthorization = options.braintree.clientAuthorization;
this.recurly = options.recurly;
this.load();
}

/**
* Loads Braintree client and modules
*/
load () {
debug('loading Braintree libraries');
loadScript(`https://js.braintreegateway.com/web/${BRAINTREE_CLIENT_VERSION}/js/client.min.js`, () => {
const part = after(2, this.initialize.bind(this));
loadScript(`https://js.braintreegateway.com/web/${BRAINTREE_CLIENT_VERSION}/js/paypal.min.js`, part);
loadScript(`https://js.braintreegateway.com/web/${BRAINTREE_CLIENT_VERSION}/js/data-collector.min.js`, part);
});

const initialize = this.initialize.bind(this);

if (clientAvailable()) modules();
else get(`https://js.braintreegateway.com/web/${BRAINTREE_CLIENT_VERSION}/js/client.min.js`, modules);

function modules () {
const part = after(2, initialize);
if (clientAvailable('paypal')) part();
else get(`https://js.braintreegateway.com/web/${BRAINTREE_CLIENT_VERSION}/js/paypal.min.js`, part);
if (clientAvailable('dataCollector')) part();
else get(`https://js.braintreegateway.com/web/${BRAINTREE_CLIENT_VERSION}/js/data-collector.min.js`, part);
}

function clientAvailable (module) {
const bt = global.braintree;
return bt && bt.client && bt.client.VERSION === BRAINTREE_CLIENT_VERSION && (module ? module in bt : true);
}

function get (uri, done) {
loadScript(uri, (error, script) => {
if (error) this.fail('paypal-braintree-load-error', { error });
else done();
});
}
}

/**
* Initializes a Braintree client, device data collection module, and paypal client
*/
initialize () {
if (!global.braintree) return this.error('paypal-braintree-load-error');
if (!global.braintree) return this.fail('paypal-braintree-load-error');
debug('Initializing Braintree client');

const authorization = this.config.clientAuthorization;
const braintree = global.braintree;

braintree.client.create({ authorization }, (error, client) => {
if (error) return this.error('paypal-braintree-api-error', { error });
if (error) return this.fail('paypal-braintree-api-error', { error });
debug('Braintree client created');

braintree.dataCollector.create({ client, paypal: true }, (error, collector) => {
if (error) return this.error('paypal-braintree-api-error', { error });
if (error) return this.fail('paypal-braintree-api-error', { error });
debug('Device data collector created');
this.deviceFingerprint = collector.deviceData;
braintree.paypal.create({ client }, (error, paypal) => {
if (error) return this.error('paypal-braintree-api-error', { error });
if (error) return this.fail('paypal-braintree-api-error', { error });
debug('PayPal client created');
this.paypal = paypal;
this.ready = true;
this.emit('ready');
});
});
});
Expand All @@ -72,11 +89,14 @@ export class BraintreePayPal extends Emitter {
* @emit 'token'
* @emit 'cancel'
*/
start () {
if (!this.ready) return this.error('paypal-braintree-not-ready');
start (...args) {
if (this.failure) return super.start(...args);
if (!this.readyState) return this.error('paypal-braintree-not-ready');

let tokenOpts = Object.assign({}, this.config.display, { flow: 'vault' });

// Tokenize with Braintree
this.paypal.tokenize({ flow: 'vault' }, (error, payload) => {
this.paypal.tokenize(tokenOpts, (error, payload) => {
if (error) {
if (error.code === 'PAYPAL_POPUP_CLOSED') return this.emit('cancel');
return this.error('paypal-braintree-tokenize-braintree-error', { error });
Expand All @@ -89,24 +109,27 @@ export class BraintreePayPal extends Emitter {
}

// Tokenize with Recurly
this.recurly.request('post', '/paypal/token', { payload }, (error, token) => {
this.recurly.request('post', '/paypal/token', { type: 'braintree', payload }, (error, token) => {
if (error) return this.error('paypal-braintree-tokenize-recurly-error', { error });
this.emit('token', token);
});
});
}

/**
* Creates and emits a RecurlyError
* Logs a failure to initialize Braintree
*
* @param {...Mixed} params to be passed to the Recurlyerror factory
* @return {RecurlyError}
* @emit 'error'
* @private
* @param {String} reason
* @param {Object} options
* @return {PayPal}
*/
error (...params) {
let err = params[0] instanceof Error ? params[0] : errors(...params);
this.emit('error', err);
return err;
fail (reason, options) {
if (this.failure) return;
debug('Failure scenario encountered. Falling back to direct PayPal flow', reason, options);
let failure = this.failure = {};
if (reason) failure.error = this.error(reason, options);
this.emit('failure', failure);
this.emit('ready');
return failure;
}
}
15 changes: 15 additions & 0 deletions lib/recurly/paypal/factory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {PayPal} from './';
import {BraintreePayPal} from './braintree';

/**
* PayPal instantiation factory
*
* @param {Object} options
* @param {Function} done
* @return {PayPal}
*/
export function factory (options, done) {
options = Object.assign({}, options, { recurly: this });
if (options.braintree) return new BraintreePayPal(options);
return new PayPal(options);
}
69 changes: 55 additions & 14 deletions lib/recurly/paypal/index.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,66 @@
import pick from 'lodash.pick';
import Emitter from 'component-emitter';
import {BraintreePayPal} from './braintree';
import {Recurly} from '../../recurly';
import Pricing from '../pricing';
import errors from '../../errors';

const debug = require('debug')('recurly:paypal');

/**
* Instantiation factory
*
* @param {Object} options
* @param {Function} done
* @return {PayPal}
*/
export function factory (options, done) {
options = Object.assign({}, options, { recurly: this });
if (options.braintree) return new BraintreePayPal(options);
return new PayPal(options);
};
const DISPLAY_OPTIONS = [
'useraction',
'amount',
'currency',
'displayName',
'locale',
'enableShippingAddress',
'shippingAddressOverride',
'shippingAddressEditable',
'billingAgreementDescription',
'landingPageType'
];

export class PayPal extends Emitter {
constructor (options) {
super();
this.once('ready', () => this.readyState = 1)
this.readyState = 0;
this.config = {};
this.configure(options);
}

ready (done) {
if (this.readyState > 0) done();
else this.once('ready', done);
}

configure (options) {
if (!(options.recurly instanceof Recurly)) return this.error('paypal-factory-only');
this.recurly = options.recurly;

this.config.constructorOptions = options;
this.config.display = {};

// PayPal EC flow display customization
if (typeof options.display === 'object') {
this.config.display = pick(options.display, DISPLAY_OPTIONS);
}

// Bind pricing information to display options
if (options.pricing instanceof Pricing) {
this.pricing = options.pricing;

// Set initial price if available
if (this.pricing.price) {
this.config.display.amount = this.pricing.price.now.total;
this.config.display.currency = this.pricing.price.currency.code;
}

// React to price changes
this.pricing.on('change', price => {
this.config.display.amount = price.now.total;
this.config.display.currency = price.currency.code;
});
}
}

/**
Expand Down Expand Up @@ -58,4 +99,4 @@ export class PayPal extends Emitter {
export function deprecatedPaypal (data, done) {
debug('start');
this.open('/paypal/start', data, done);
};
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"lodash.merge": "*",
"lodash.omit": "*",
"lodash.partial": "*",
"lodash.pick": "^4.4.0",
"map-component": "0.0.1",
"mixin": "https://github.com/kewah/mixin#0.1.0",
"mutation-observer": "^1.0.2",
Expand Down
Loading

0 comments on commit 76b34d1

Please sign in to comment.