lib/recurly/pricing/checkout/index.js
import clone from 'component-clone';
import find from 'component-find';
import intersection from 'intersect';
import isEmpty from 'lodash.isempty';
import Promise from 'promise';
import uniq from 'array-unique';
import uid from '../../../util/uid';
import errors from '../../errors';
import { Pricing } from '../';
import PricingPromise from '../promise';
import SubscriptionPricing from '../subscription';
import EmbeddedSubscriptionPricing from '../subscription/embedded';
import Calculations from './calculations';
import Attachment from './attachment';
const debug = require('debug')('recurly:pricing:checkout-pricing');
/**
* CheckoutPricing
*
* Constructs a pricing model for a checkout purchase. Call methods
* on an instance to add or remove items from the pricing model, and
* listen for changes to the price.
*
* This class allows you to provide basic details about a checkout purchase,
* such as plan code, item code, and address, and receive detailed pricing
* information about the overall purchase, including item costs, tax estimates,
* complex discounting, and currency changes.
*
* Example
*
* ```
* const checkoutPricing = recurly.Pricing.Checkout();
* checkoutPricing.on('change', price => console.log(price));
* checkoutPricing
* .currency('USD')
* .subscription(recurly.Pricing.Subscription().plan('my-plan').done())
* .adjustment({ itemCode: 'my-item-code', quantity: 2 })
* .address({ country: 'US', postalCode: '94110' })
* .coupon('my-coupon-code')
* .done();
* ```
*
* @class
*/
export default class CheckoutPricing extends Pricing {
constructor (...args) {
super(...args);
this.debug = debug;
this.recurly.report('pricing:checkout:create');
}
reset () {
super.reset();
this.items.subscriptions = [];
this.items.adjustments = [];
}
get Calculations () {
return Calculations;
}
get PRICING_METHODS () {
return super.PRICING_METHODS.concat([
'address',
'adjustment',
'coupon',
'currency',
'giftCard',
'shippingAddress',
'subscription',
'tax'
]);
}
get validAdjustments () {
return this.items.adjustments.filter(adj => adj.currency === this.items.currency);
}
get validSubscriptions () {
return this.items.subscriptions.filter(sub => sub.isValid);
}
// Currency codes common to all subscriptions
get subscriptionCurrencies () {
return intersection(this.validSubscriptions.map(s => Object.keys(s.items.plan.price)));
}
// Plan codes of all subscriptions
get subscriptionPlanCodes () {
return uniq(this.validSubscriptions.map(s => s.items.plan.code));
}
findSubscriptionById (id) {
const embeddedSubscription = find(this.items.subscriptions, esub => esub.id === id);
if (embeddedSubscription) return embeddedSubscription.subscription;
}
/**
* Attachment factory
*
* @param {HTMLElement} container - element on which to attach
*/
attach (container) {
if (this.attachment) this.attachment.detach();
this.attachment = new Attachment(this, container);
this.attachment.once('ready', () => this.emit('attached'));
return this.attachment;
}
/**
* Updates currency code. Will reject if the subscription plans do not support the given code
*
* @param {String} code Currency code
* @return {PricingPromise}
* @public
*/
currency (code) {
return new PricingPromise((resolve, reject) => {
if (this.items.currency === code) return resolve(this.items.currency);
if (!this.subscriptionsAllowCurrency(code)) {
return this.error(errors('invalid-currency', {
currency: code,
allowed: this.subscriptionCurrencies
}), reject, 'currency');
}
this.items.currency = code;
// Propagate currency changes to each subscription
Promise.all(this.validSubscriptions.map(sub => {
debug('checkout currency has changed. Updating subscription', sub);
return sub.currency(code).reprice();
}))
// Eject gift cards on currency change
.then(() => this.giftCard(null))
.then(() => this.resolveAndEmit('set.currency', code, resolve));
}, this);
}
/**
* Adds a subscription
*
* - Ignores address and tax info from subscriptions
* - Removes subscription discounts
*
* @param {SubscriptionPricing} subscription
* @return {PricingPromise}
* @public
*/
subscription (subscription) {
return new PricingPromise((resolve, reject) => {
if (!(subscription instanceof SubscriptionPricing)) {
return this.error(errors('invalid-option', {
name: 'subscription',
expect: 'a {recurly.Pricing.Subscription}'
}), reject, 'subscription');
}
// Do nothing if we already have this subscription
if (this.items.subscriptions.some(esub => esub.subscription === subscription)) {
return resolve(subscription);
}
const plan = subscription.items.plan;
// If the subscription currencies do not match the checkout currency, attempt to resolve it.
// If it cannot be resolved, reject the subscription
if (this.items.currency && plan) {
const planCurrencies = Object.keys(plan.price);
if (!~planCurrencies.indexOf(this.currencyCode)) {
try {
this.resolveCurrency(planCurrencies);
} catch (e) {
return this.error(errors('invalid-subscription-currency', {
checkoutCurrency: this.currencyCode,
checkoutSupportedCurrencies: this.subscriptionCurrencies,
subscriptionPlanCurrencies: planCurrencies
}), reject, 'currency');
}
}
}
if (!this.items.currency) this.currency(subscription.items.currency);
const emitSubscriptionSetter = name => {
return item => this.emit(`set.${name}`, { subscription: { id: subscription.id }, [name]: item });
};
const addSubscription = () => {
subscription.on('change:external', () => this.reprice());
subscription.on('set.plan', emitSubscriptionSetter('plan'));
subscription.on('set.addon', emitSubscriptionSetter('addon'));
this.items.subscriptions.push(new EmbeddedSubscriptionPricing(subscription, this));
this.resolveAndEmit('set.subscription', subscription, resolve, { copy: false });
};
// Removes any subscription coupons and gift cards
if (subscription.isValid) {
subscription.coupon(null).giftcard(null).then(addSubscription);
} else {
addSubscription();
}
}, this);
}
/**
* @typedef {Object} CheckoutPricing~Adjustment
*
* @property {String} [itemCode] item code reference. If provided,
* the amount and tax properties will be populated
* from the given item. an itemCode may not be used to
* modify an adjustment in-place.
* @property {Number} [amount] in unit price (1.0 for USD, etc)
* @property {Number} [quantity=1] number of units
* @property {String} [id=uid()] unique identifier. Use this value to modify an
* adjustment in-place
* @property {String} [currency=this.items.currency] currency code
* @property {Boolean} [taxExempt=false] whether this adjustment is tax exempt
* @property {String} [taxCode] taxation code
*/
/**
* Adds a one-time charge or credit a.k.a. adjustment
*
* @param {CheckoutPricing~Adjustment} adjustment
* @return {PricingPromise}
* @public
*/
adjustment ({ itemCode = null, amount, quantity, id = uid(), currency, taxExempt, taxCode }) {
const { recurly } = this;
return new PricingPromise((resolve, reject) => {
let existingAdjustment = find(this.items.adjustments, a => a.id === id);
let validateAmount = true;
if (itemCode || (existingAdjustment && typeof amount === 'undefined')) {
validateAmount = false;
}
if (validateAmount) {
amount = Number(amount);
if (!isFinite(amount)) {
let err = { name: 'amount', expect: 'a finite Number' };
return this.error(errors('invalid-option', err), reject, 'adjustment');
}
}
if (existingAdjustment && existingAdjustment.itemCode && !itemCode) {
itemCode = existingAdjustment.itemCode;
}
new Promise((resolve, reject) => {
if (!itemCode) return resolve();
return recurly.item({ itemCode }).then(item => {
const expectedCurrency = currency
|| (existingAdjustment && existingAdjustment.currency)
|| this.items.currency;
if (typeof amount === 'undefined') {
const itemPrice = find(item.currencies, c => c.currency_code === expectedCurrency);
if (itemPrice) {
amount = itemPrice.unit_amount;
} else {
return reject(errors('invalid-item-currency', { itemCode, currency: expectedCurrency }));
}
}
taxCode = item.tax_code;
taxExempt = item.tax_exempt;
resolve();
}, reject);
})
.then(() => {
// New adjustment defaults
if (!existingAdjustment) {
currency = currency || this.items.currency;
quantity = coerceAdjustmentQuantity(quantity);
taxExempt = !!taxExempt;
}
return { amount, quantity, id, currency, taxExempt, taxCode, itemCode };
})
.then(adjustment => {
if (existingAdjustment) {
if (typeof amount === 'undefined') delete adjustment.amount;
if (typeof currency === 'undefined') delete adjustment.currency;
if (typeof quantity === 'undefined') {
delete adjustment.quantity;
} else {
quantity = coerceAdjustmentQuantity(quantity);
}
if (existingAdjustment.itemCode || typeof taxCode === 'undefined') {
delete adjustment.taxCode;
}
if (existingAdjustment.itemCode || typeof taxExempt === 'undefined') {
delete adjustment.taxExempt;
}
adjustment = Object.assign(existingAdjustment, adjustment);
} else {
this.items.adjustments.push(adjustment);
}
this.resolveAndEmit('set.adjustment', adjustment, resolve);
})
.catch(err => {
this.error(err, reject, 'adjustment');
});
}, this);
}
/**
* Updates coupon. Manages a single coupon on the item set, unsetting any exisitng
* coupon
*
* `set.coupon` is emitted when a valid coupon is set
* `unset.coupon` is emitted when an existing valid coupon is removed
* `error.coupon` is emitted when the requested coupon is invalid
*
* @param {String} couponCode
* @return {PricingPromise}
* @public
*/
coupon (couponCode) {
if (~this.couponCodes.indexOf(couponCode)) {
return new PricingPromise(resolve => resolve(clone(this.items.coupon)), this);
}
return new PricingPromise(resolve => resolve(), this)
.then(() => {
if (!this.items.coupon) return;
const priorCoupon = clone(this.items.coupon);
debug('unset.coupon');
return this.remove({ coupon: priorCoupon.code })
.then(() => {
this.emit('unset.coupon', priorCoupon);
});
})
.then(() => new PricingPromise((resolve, reject) => {
// A blank coupon is handled as ok
if (!couponCode) return resolve();
this.recurly.coupon({ plans: this.subscriptionPlanCodes, coupon: couponCode }, (err, coupon) => {
if (err) {
return this.error(err, reject, 'coupon');
} else {
this.items.coupon = coupon;
this.resolveAndEmit('set.coupon', coupon, resolve);
}
});
}));
}
/**
* Updates address
*
* @param {Object} address
* @param {String} address.country
* @param {String} address.postalCode
* @param {String} address.vatNumber
* @return {PricingPromise}
* @public
*/
address (address) {
return new PricingPromise(this.itemUpdateFactory('address', address), this);
}
/**
* Updates shipping address
*
* @param {Object} address
* @param {String} address.country
* @param {String} address.postalCode
* @param {String} address.vatNumber
* @return {PricingPromise}
* @public
*/
shippingAddress (address) {
return new PricingPromise(this.itemUpdateFactory('shippingAddress', address), this);
}
/**
* Updates tax info
*
* @param {Object} tax
* @param {String} tax.vatNumber
* @param {Object} [tax.amounts] specific tax amounts. Overrides automated tax rate calculations
* @param {Object} [tax.amounts.now] specific tax to apply on the immediate charge
* @param {Object} [tax.amounts.next=0] specific tax to apply on the next billing cycle
* @public
*/
tax (tax) {
this.guardTaxSignature(tax);
return new PricingPromise(this.itemUpdateFactory('tax', tax), this);
}
/**
* Updates gift card
*
* @param {String} code Gift card code
* @return {PricingPromise} [description]
* @public
*/
giftCard (code) {
const unset = () => {
if (!this.items.giftCard) return;
debug('unset.giftCard');
delete this.items.giftCard;
this.emit('unset.giftCard');
};
return new PricingPromise((resolve, reject) => {
if (!code) {
unset();
return resolve();
}
this.recurly.giftCard({ code }, (err, giftCard) => {
if (err) {
return this.error(err, reject, 'giftCard');
} else {
unset();
if (this.items.currency !== giftCard.currency){
return this.error(errors('gift-card-currency-mismatch'), reject, 'giftCard');
} else {
this.items.giftCard = giftCard;
this.resolveAndEmit('set.giftCard', giftCard, resolve);
}
}
});
}, this);
}
/**
* Adds handlers to remove subscriptions, adjustments, and coupons
*
* @param {Object} options
* @param {Object} options[item] Must contain an id property. The key name
* corresponds to the item type to be removed (ex: 'subscription')
* @public
*/
remove (options) {
return new PricingPromise((resolve, reject) => {
// Expects SubscriptionPricing or EmbeddedSubscriptionPricing
if (options.subscription) {
this.removeFromSet('subscriptions', options.subscription);
} else if (options.adjustment) {
this.removeFromSet('adjustments', options.adjustment);
} else {
if (options.coupon) {
// Remove the coupon from embedded subscriptions
Promise.all(this.validSubscriptions.map(sub => sub.coupon().reprice({ internal: true })))
.then(() => super.remove(options).then(resolve, reject));
} else {
super.remove(options).then(resolve, reject);
}
}
});
}
/**
* Removes a set item (subscription or adjustment)
*
* @param {String} name item name (ex: 'subscription', 'adjustment')
* @param {Object} itemToRemove item to remove. Must have an id property
* @private
*/
removeFromSet (name, itemToRemove) {
this.items[name] = this.items[name].filter(item => item.id !== itemToRemove.id);
}
/**
* Checks whether the current subscriptions will allow the given currency code
*
* @param {String} code Currency code
* @return {Boolean}
* @private
*/
subscriptionsAllowCurrency (code) {
if (isEmpty(this.items.subscriptions)) return true;
return !!~this.subscriptionCurrencies.indexOf(code);
}
/**
* Attempts to set a common currency from amongst the
* current subscriptions and the provided codes.
*
* @param {Array} code Array of {String} currency codes
* @param {Object} [options]
* @param {Boolean} [options.commit=true] whether to commit a currency change
* @throws {Error} If a common currency cannot be found
* @private
*/
resolveCurrency (codes, { commit = true } = {}) {
let commonCurrencies;
// If we have subscriptions already, check that we have common currencies. Otherwise, we may proceed
if (isEmpty(this.validSubscriptions)) {
commonCurrencies = codes;
} else {
commonCurrencies = intersection(this.subscriptionCurrencies, codes);
}
if (isEmpty(commonCurrencies)) throw new Error('unresolvable');
if (commit) return this.currency(commonCurrencies[0]);
}
/**
* Binds important events to the EventDispatcher
*
* @protected
*/
bindReporting () {
super.bindReporting('pricing:checkout');
const report = (...args) => this.recurly.report(...args);
this.on('attached', () => report('pricing:checkout:attached'));
this.on('set.subscription', () => report('pricing:checkout:set:subscription'));
this.on('change:external', price => report('pricing:checkout:change', {
price: {
couponCodes: this.couponCodes,
currency: this.currencyCode,
discount: price.now.discount,
giftCard: price.now.giftCard,
items: price.now.items.map(item => ({
type: item.type,
amount: item.amount,
quantity: item.quantity,
})),
taxes: price.now.taxes,
total: price.now.total,
totalNext: price.next.total
}
}));
}
}
function coerceAdjustmentQuantity (quantity) {
quantity = parseInt(quantity, 10);
if (isNaN(quantity)) quantity = 1;
return quantity;
}