diff --git a/ANALYTICS.md b/ANALYTICS.md deleted file mode 100644 index 9bc5e0d4f..000000000 --- a/ANALYTICS.md +++ /dev/null @@ -1,68 +0,0 @@ -Analytics endpoints -==== - -# Purpose - -To collect data about various events that occur during a customer's interaction -with recurly.js, from card entry, to error occurrences, abandonment, tokenization, -with understanding of customer interaction timeframes, dropoffs, and funnel behaviors - -# POST /js/v1/event - -``` -Class RecurlyJsEvent - -{ - category: 'user', // 'merchant', 'recurly' - action: 'tokenize-start', - customerId: 'abc123', - sessionId: 'xyz789', - url: 'https://merchant.com/checkout', - initiatedAt: '2018-03-12T19:01:51.511Z', // exact time the event occurred in the browser, UTC - meta: { - method: 'recurly.token', - value: '', // in case of some value being conveyed i.e. during pricing - // arbitrary: 'values' - // ... - } -} -``` - -## Data applied by headers - -{ - userAgent: 'Mozilla/5.0 ...', - receivedAt: '2018-03-12T19:01:51.975Z', // time at which the event was received - remoteIP: '0.0.0.0' -} - -# Logging services - -1. Splunk -2. [Google Analytics Measurement Protocol] - 1. https://developers.google.com/analytics/devguides/collection/protocol/v1/devguide - -# Client requirements - -1. recurly.event method -2. localStorage.recurly.customerId: uuid generated per customer, stored indefinitely -3. localStorage.recurly.sessionId: uuid generated per customer, stored short-term, TTL 1h -4. recurly.event calls: tbd by org requirements - -# Tasks - -1. Create /js/v1/event endpoint, RecurlyJsEvent, and Splunk logging routine -2. Create client customer and session id -3. Create client event method -4. Create events - 1. tokenization - 2. hosted field interaction: value entry, focus, blur. Each with field state conveyed - 3. pricing manipulations: plan choice, coupon entry, etc - 4. user error occurrences - 5. runtime error occurrences - 6. [further scope and number tbd by org requirements] - -# Remaining discovery - -1. Assess initial org requirements/asks from marketing, sales, product, engineering. This blocks task 4.6 -2. Assess merchant impact of customer data collection. Are we limited by our ToS or implicit recurly-merchant relationship ethics on what customer actions and data we should collect? diff --git a/lib/recurly.js b/lib/recurly.js index 29a04f77c..e309b224d 100644 --- a/lib/recurly.js +++ b/lib/recurly.js @@ -19,7 +19,7 @@ import {factory as Frame} from './recurly/frame'; import {factory as PayPal} from './recurly/paypal'; import {deprecated as deprecatedPaypal} from './recurly/paypal/strategy/direct'; import {Bus} from './recurly/bus'; -import {EventDispatcher} from './recurly/event'; +import {Reporter} from './recurly/reporter'; import {Fraud} from './recurly/fraud'; import {HostedFields, FIELD_TYPES} from './recurly/hosted-fields'; import {Request} from './recurly/request'; @@ -109,7 +109,7 @@ export class Recurly extends Emitter { token: bankAccount.token.bind(this), bankInfo: bankAccount.bankInfo.bind(this) }; - this.event = new EventDispatcher({ recurly: this }); + this.reporter = new Reporter({ recurly: this }); this.request = new Request({ recurly: this }); // @deprecated -- Old method of instantiating single-subscription Pricing @@ -117,8 +117,8 @@ export class Recurly extends Emitter { this.Pricing.Checkout = () => new CheckoutPricing(this); this.Pricing.Subscription = () => new SubscriptionPricing(this); - this.once('ready', () => this.event.send({ name: 'ready' })); - this.bindDispatchedEvents(); + this.once('ready', () => this.report('ready')); + this.bindReporting(); } /** @@ -232,7 +232,7 @@ export class Recurly extends Emitter { if (!this.configured) { this.configured = true; this.emit('configured'); - if (this.event) this.event.send({ name: 'configured' }); + this.report('configured'); } } @@ -292,19 +292,29 @@ export class Recurly extends Emitter { } } + /** + * Reports an event to the Reporter if it is available + * + * @private + */ + report (...args) { + if (!this.reporter) return; + this.reporter.send(...args); + } + /** * Binds important events to the EventDispatcher * * @private */ - bindDispatchedEvents () { + bindReporting () { if (!this.isParent) return; ['focus', 'blur'].forEach(eventName => { this.on(`hostedField:${eventName}`, ({ type }) => { const state = this.hostedFields.state[type]; let meta = pick(state, ['valid', 'empty']); if (state.brand) meta.brand = state.brand; - this.event.send({ name: `hosted-field:${eventName}`, meta }); + this.report(`hosted-field:${eventName}`, meta); }); }); } diff --git a/lib/recurly/errors.js b/lib/recurly/errors.js index aed60e813..ae8a96d32 100644 --- a/lib/recurly/errors.js +++ b/lib/recurly/errors.js @@ -1,4 +1,4 @@ -import {EventDispatcher} from './event'; +import {Reporter} from './reporter'; const BASE_URL = 'https://dev.recurly.com/docs/recurly-js-'; const ERRORS = [ @@ -220,7 +220,7 @@ class ErrorDirectory { * @param {String} code * @param {Object} [context] arbitrary error property dictionary * @param {Object} [options] - * @param {EventDispatcher} [options.reporter] EventDispatcher instance to report errors + * @param {Reporter} [options.reporter] Reporter instance to report errors * @return {RecurlyError} * @throws {Error} if the requested error is not in the directory */ @@ -251,7 +251,7 @@ function recurlyErrorFactory (definition) { * @param {String} message error message * @param {Object} context suplementary error data * @param {Object} [options] - * @param {EventDispatcher} [options.reporter] EventDispatcher instance used to report an error + * @param {Reporter} [options.reporter] Reporter instance used to report an error * @param {String} [help] documentation reference */ function RecurlyError (context = {}, options = {}) { @@ -272,13 +272,13 @@ function recurlyErrorFactory (definition) { this.message += ` (need help? ${this.help})`; } - if (this.reporter instanceof EventDispatcher) { + if (this.reporter instanceof Reporter) { let type = 'client'; if (this.classification) type += `:${this.classification}`; // Warning: any errors that occur in this code path risk // a stack overflow if they include a reporter - this.reporter.send({ name: 'error', type }); + this.reporter.send('error', { type }); } } diff --git a/lib/recurly/pricing/checkout/index.js b/lib/recurly/pricing/checkout/index.js index 2b48cf774..df98bc588 100644 --- a/lib/recurly/pricing/checkout/index.js +++ b/lib/recurly/pricing/checkout/index.js @@ -1,3 +1,4 @@ +import clone from 'component-clone'; import find from 'component-find'; import intersection from 'intersect'; import isEmpty from 'lodash.isempty'; @@ -23,6 +24,7 @@ export default class CheckoutPricing extends Pricing { constructor (...args) { super(...args); this.debug = debug; + this.recurly.report('pricing:checkout:create'); } reset () { @@ -109,11 +111,7 @@ export default class CheckoutPricing extends Pricing { })) // Eject gift cards on currency change .then(() => this.giftCard(null)) - .then(() => { - debug('set.currency'); - this.emit('set.currency', code); - resolve(code); - }); + .then(() => this.resolveAndEmit('set.currency', code, resolve)); }, this); } @@ -171,9 +169,7 @@ export default class CheckoutPricing extends Pricing { subscription.on('set.plan', emitSubscriptionSetter('plan')); subscription.on('set.addon', emitSubscriptionSetter('addon')); this.items.subscriptions.push(new EmbeddedSubscriptionPricing(subscription, this)); - debug('set.subscription'); - this.emit('set.subscription', subscription); - resolve(subscription); + this.resolveAndEmit('set.subscription', subscription, resolve, { copy: false }); } // Removes any subscription coupons and gift cards @@ -238,9 +234,7 @@ export default class CheckoutPricing extends Pricing { this.items.adjustments.push(adjustment); } - debug('set.adjustment'); - this.emit('set.adjustment', adjustment); - resolve(adjustment); + this.resolveAndEmit('set.adjustment', adjustment, resolve); }, this); } @@ -248,37 +242,39 @@ export default class CheckoutPricing extends Pricing { * 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) { - const unset = () => { - debug('unset.coupon'); - delete this.items.coupon; - this.emit('unset.coupon'); - }; + 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 this.remove({ coupon: this.items.coupon.code }); + 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) { - unset(); - return resolve(); - } + if (!couponCode) return resolve(); this.recurly.coupon({ plans: this.subscriptionPlanCodes, coupon: couponCode }, (err, coupon) => { - if (err && err.code === 'not-found') unset(); if (err) { return this.error(err, reject, 'coupon'); } else { - debug('set.coupon'); this.items.coupon = coupon; - this.emit('set.coupon', coupon); - resolve(coupon); + this.resolveAndEmit('set.coupon', coupon, resolve); } }); })); @@ -352,10 +348,8 @@ export default class CheckoutPricing extends Pricing { if (this.items.currency !== giftCard.currency){ return this.error(errors('gift-card-currency-mismatch'), reject, 'giftCard'); } else { - debug('set.giftCard'); this.items.giftCard = giftCard; - this.emit('set.giftCard', giftCard); - resolve(giftCard); + this.resolveAndEmit('set.giftCard', giftCard, resolve); } } }); @@ -441,9 +435,26 @@ export default class CheckoutPricing extends Pricing { * * @protected */ - bindDispatchedEvents () { - super.bindDispatchedEvents(); - const send = (...args) => this.recurly.event && this.recurly.event.send(...args); - this.on('set.subscription', subscription => send({ name: 'pricing:set:subscription' })); + bindReporting () { + super.bindReporting('pricing:checkout'); + const report = (...args) => this.recurly.report(...args); + this.on('attached', () => report('pricing:checkout:attached')); + this.on('set.subscription', 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 + } + })); } } diff --git a/lib/recurly/pricing/index.js b/lib/recurly/pricing/index.js index eca34c269..4972a7183 100644 --- a/lib/recurly/pricing/index.js +++ b/lib/recurly/pricing/index.js @@ -1,5 +1,6 @@ import currencySymbolFor from 'currency-symbol-map'; import Emitter from 'component-emitter'; +import clone from 'component-clone'; import find from 'component-find'; import pick from 'lodash.pick'; import PricingPromise from './promise'; @@ -15,7 +16,7 @@ export class Pricing extends Emitter { this.debug = debug; this.reset(); this.reprice(); - this.bindDispatchedEvents(); + this.bindReporting(); } get Calculations () { @@ -51,6 +52,11 @@ export class Pricing extends Emitter { return currencySymbolFor(this.currencyCode); } + get couponCodes () { + if (this.items.coupon) return [this.items.coupon.code]; + return []; + } + /** * Resets items and sets defaults * @@ -76,9 +82,8 @@ export class Pricing extends Emitter { new this.Calculations(this, price => { if (JSON.stringify(price) === JSON.stringify(this.price)) return resolve(price); this.price = price; - if (!internal) this.emit('change:external', price); - this.emit('change', price); - resolve(price); + const priceCopy = this.resolveAndEmit('change', price, resolve); + if (!internal) this.emit('change:external', priceCopy); }); }, this).nodeify(done); } @@ -118,7 +123,7 @@ export class Pricing extends Emitter { } else { return this.error(errors('unremovable-item', { type: prop, - id: id, + id, reason: 'does not exist on this pricing instance.' }), reject); } @@ -126,25 +131,44 @@ export class Pricing extends Emitter { }, this).nodeify(done); } + /** + * Utility to emit an event and call a PricingPromise resolver + * with a mutation-safe copy of the item object + * + * @param {string} event event name + * @param {Object} object pricing item + * @param {Function} resolve PricingPromise resolver + * @param {Boolean} [copy] whether to clone the pricing item + * @return {Object} object or object clone + * @private + */ + resolveAndEmit (event, object, resolve, { copy = true } = {}) { + this.debug(event); + if (typeof object !== 'object') copy = false; + if (copy) object = clone(object); + this.emit(event, object); + resolve(object); + return object; + } + /** * Binds important events to the EventDispatcher * * @protected */ - bindDispatchedEvents () { - const send = (...args) => this.recurly.event && this.recurly.event.send(...args); - const setGiftCard = giftCard => send({ name: 'pricing:set:giftCard', meta: { amount: giftCard.unit_amount } }); - const unsetGiftCard = () => send({ name: 'pricing:unset:giftCard' }); - this.on('set.addon', addOn => send({ name: 'pricing:set:addOn', meta: pick(addOn, ['code', 'quantity']) })); - this.on('set.coupon', coupon => send({ name: 'pricing:set:coupon', meta: { code: coupon.code } })); - this.on('set.currency', code => send({ name: 'pricing:set:currency', meta: { code } })); + bindReporting (domain = `pricing`) { + const report = (...args) => this.recurly.report(...args); + const setGiftCard = giftCard => report(`${domain}:set:giftCard`, { amount: giftCard.unit_amount }); + const unsetGiftCard = () => report(`${domain}:unset:giftCard`); + this.on('set.addon', addOn => report(`${domain}:set:addOn`, pick(addOn, ['code', 'quantity']))); + this.on('set.coupon', coupon => report(`${domain}:set:coupon`, { code: coupon.code })); + this.on('set.currency', code => report(`${domain}:set:currency`, { code })); this.on('set.gift_card', setGiftCard); this.on('set.giftCard', setGiftCard); - this.on('set.plan', plan => send({ name: 'pricing:set:plan', meta: { code: plan.code } })); - this.on('unset.coupon', () => send({ name: 'pricing:unset:coupon' })); + this.on('set.plan', plan => report(`${domain}:set:plan`, { code: plan.code })); + this.on('unset.coupon', () => report(`${domain}:unset:coupon`)); this.on('unset.gift_card', unsetGiftCard); this.on('unset.giftCard', unsetGiftCard); - this.on('change:external', price => send({ name: 'pricing:change', meta: { price } })); } /** @@ -160,12 +184,8 @@ export class Pricing extends Emitter { if (JSON.stringify(object) === JSON.stringify(this.items[name])) { return resolve(this.items[name]); } - this.items[name] = object; - - debug(`set.${name}`); - this.emit(`set.${name}`, object); - resolve(object); + this.resolveAndEmit(`set.${name}`, object, resolve); }; } diff --git a/lib/recurly/pricing/subscription/embedded.js b/lib/recurly/pricing/subscription/embedded.js index ebef7da4d..f99df689d 100644 --- a/lib/recurly/pricing/subscription/embedded.js +++ b/lib/recurly/pricing/subscription/embedded.js @@ -8,7 +8,7 @@ export const DISABLED_METHODS = [ 'currency', 'giftcard', 'shippingAddress', - 'bindDispatchedEvents' + 'bindReporting' ]; export const DEFERRED_METHODS = [ diff --git a/lib/recurly/pricing/subscription/index.js b/lib/recurly/pricing/subscription/index.js index 1c67a19cf..000b2edd7 100644 --- a/lib/recurly/pricing/subscription/index.js +++ b/lib/recurly/pricing/subscription/index.js @@ -1,3 +1,4 @@ +import clone from 'component-clone'; import uuid from 'uuid/v4'; import {Pricing, findByCode} from '../'; import PricingPromise from '../promise'; @@ -19,6 +20,7 @@ export default class SubscriptionPricing extends Pricing { super(recurly); this.id = id; this.debug = debug; + this.recurly.report('pricing:subscription:create'); } get Calculations () { @@ -84,18 +86,12 @@ export default class SubscriptionPricing extends Pricing { return new PricingPromise((resolve, reject) => { if (currentPlan && currentPlan.code === planCode) { currentPlan.quantity = quantity; - return resolve(currentPlan); + return resolve(clone(currentPlan)); } this.recurly.plan(planCode, (err, plan) => { if (err) return this.error(err, reject, 'plan'); - const finish = () => { - debug('set.plan'); - this.emit('set.plan', plan); - resolve(plan); - }; - plan.quantity = quantity; this.items.plan = plan; @@ -103,6 +99,8 @@ export default class SubscriptionPricing extends Pricing { this.currency(Object.keys(plan.price)[0]); } + const finish = () => this.resolveAndEmit('set.plan', plan, resolve); + // If we have a coupon, it must be reapplied if (this.items.coupon) { this.coupon(this.items.coupon.code).then(finish, finish); @@ -123,8 +121,6 @@ export default class SubscriptionPricing extends Pricing { * @public */ addon (addonCode, meta, done) { - const self = this; - if (typeof meta === 'function') { done = meta; meta = undefined; @@ -132,22 +128,22 @@ export default class SubscriptionPricing extends Pricing { meta = meta || {}; - return new PricingPromise(function (resolve, reject) { - if (!self.items.plan) return self.error(errors('missing-plan'), reject, 'addon'); + return new PricingPromise((resolve, reject) => { + if (!this.items.plan) return this.error(errors('missing-plan'), reject, 'addon'); - let planAddon = findByCode(self.items.plan.addons, addonCode); + let planAddon = findByCode(this.items.plan.addons, addonCode); if (!planAddon) { - return self.error(errors('invalid-addon', { - planCode: self.items.plan.code, + return this.error(errors('invalid-addon', { + planCode: this.items.plan.code, addonCode: addonCode }), reject, 'addon'); } let quantity = addonQuantity(meta, planAddon); - let addon = findByCode(self.items.addons, addonCode); + let addon = findByCode(this.items.addons, addonCode); if (quantity === 0) { - self.remove({ addon: addonCode }); + this.remove({ addon: addonCode }); } if (addon) { @@ -155,12 +151,10 @@ export default class SubscriptionPricing extends Pricing { } else { addon = JSON.parse(JSON.stringify(planAddon)); addon.quantity = quantity; - self.items.addons.push(addon); + this.items.addons.push(addon); } - debug('set.addon'); - self.emit('set.addon', addon); - resolve(addon); + this.resolveAndEmit('set.addon', addon, resolve); }, this).nodeify(done); } @@ -189,20 +183,14 @@ export default class SubscriptionPricing extends Pricing { unsetGiftcard(); return self.error(errors('gift-card-currency-mismatch'), reject, 'gift_card'); } else { - setGiftcard(gift_card); - resolve(gift_card); + self.items.gift_card = gift_card; + self.resolveAndEmit('set.gift_card', gift_card, resolve); } } }); }, this).nodeify(done); - function setGiftcard (gift_card) { - debug('set.gift_card'); - self.items.gift_card = gift_card; - self.emit('set.gift_card', gift_card); - } - function unsetGiftcard (err) { debug('unset.gift_card'); delete self.items.gift_card; @@ -213,40 +201,42 @@ export default class SubscriptionPricing extends Pricing { /** * Updates 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} newCoupon coupon code * @param {Object} newCoupon coupon object * @param {Function} [done] callback * @public */ coupon (newCoupon, done) { - const unsetCoupon = () => { - debug('unset.coupon'); - delete this.items.coupon; - this.emit('unset.coupon'); - }; + if (~this.couponCodes.indexOf(newCoupon)) { + return new PricingPromise(resolve => resolve(clone(this.items.coupon)), this); + } return new PricingPromise((resolve, reject) => { if (!this.items.plan) return this.error(errors('missing-plan'), reject, 'coupon'); - if (this.items.coupon) this.remove({ coupon: this.items.coupon.code }); - // A blank coupon is handled as ok - if (!newCoupon) { - unsetCoupon(); - return resolve(); + if (this.items.coupon) { + const priorCoupon = clone(this.items.coupon); + debug('unset.coupon'); + this.remove({ coupon: priorCoupon.code }) + .then(() => this.emit('unset.coupon', priorCoupon)); } + // A blank coupon is handled as ok + if (!newCoupon) return resolve(); + const receiveCoupon = (err, coupon) => { - if (err && err.code === 'not-found') unsetCoupon(); if (err) { return this.error(err, reject, 'coupon'); } if (!this.couponIsValidForSubscription(coupon)) { return this.error('invalid-coupon-for-subscription', reject, 'coupon'); } else { - debug('set.coupon'); this.items.coupon = coupon; - this.emit('set.coupon', coupon); - resolve(coupon); + this.resolveAndEmit('set.coupon', coupon, resolve); } }; @@ -309,24 +299,18 @@ export default class SubscriptionPricing extends Pricing { * @public */ currency (code, done) { - const self = this; - let plan = this.items.plan; - let currency = this.items.currency; - - return new PricingPromise(function (resolve, reject) { - if (currency === code) return resolve(currency); + return new PricingPromise((resolve, reject) => { + let plan = this.items.plan; + if (this.items.currency === code) return resolve(this.items.currency); if (plan && !(code in plan.price)) { - return self.error(errors('invalid-currency', { + return this.error(errors('invalid-currency', { currency: code, allowed: Object.keys(plan.price) }), reject, 'currency'); } - self.items.currency = code; - - debug('set.currency'); - self.emit('set.currency', code); - resolve(code); + this.items.currency = code; + this.resolveAndEmit('set.currency', code, resolve); }, this).nodeify(done); } @@ -369,6 +353,29 @@ export default class SubscriptionPricing extends Pricing { return { currentPlan: plan, quantity, planCode, options, done }; } + + /** + * Binds important events to the EventDispatcher + * + * @protected + */ + bindReporting () { + super.bindReporting('pricing:subscription'); + const report = (...args) => this.recurly.report(...args); + this.on('attached', () => report('pricing:subscription:attached')); + this.on('change:external', price => report('pricing:subscription:change', { + price: { + addons: price.now.addons, + couponCodes: this.couponCodes, + currency: this.currencyCode, + discount: price.now.discount, + giftCard: price.now.gift_card, + taxes: price.now.taxes, + total: price.now.total, + totalNext: price.next.total, + } + })); + } } function addonQuantity (meta, planAddon) { diff --git a/lib/recurly/event.js b/lib/recurly/reporter.js similarity index 61% rename from lib/recurly/event.js rename to lib/recurly/reporter.js index 4bba45bda..a74e014ec 100644 --- a/lib/recurly/event.js +++ b/lib/recurly/reporter.js @@ -1,14 +1,14 @@ import {IntervalWorker} from './worker'; -const debug = require('debug')('recurly:event'); +const debug = require('debug')('recurly:reporter'); /** - * Event dispatcher + * Reports events to the API * * @param {Object} options * @param {Recurly} options.recurly */ -export class EventDispatcher { +export class Reporter { constructor ({ recurly }) { this.pool = []; this.recurly = recurly; @@ -17,19 +17,31 @@ export class EventDispatcher { this.worker.start(); } - send (options) { + /** + * Sends a reporting event to be dispatched + * + * @param {String} name event name + * @param {Object} meta event metadata + */ + send (name, meta) { if (!this.shouldDispatch) return; - this.pool.push(this.payload(options)); + this.pool.push(this.payload(name, meta)); } - // Private - + /** + * Whether events should be dispatched + * + * @return {Boolean} + * @private + */ get shouldDispatch () { return this.recurly.isParent; } /** * Delivery job. Sends events in a queued request to the events endpoint + * + * @private */ deliver () { if (this.pool.length === 0) return; @@ -40,12 +52,14 @@ export class EventDispatcher { /** * Formats an event payload * - * @param {Object} options - * @param {String} options.name event name + * @param {String} name event name + * @param {Object} [meta] event metadata + * @private */ - payload ({ name }) { + payload (name, meta) { return { name, + meta, instanceId: this.recurly.id, occurredAt: new Date().toISOString() }; diff --git a/test/errors.test.js b/test/errors.test.js index 069d53b9d..08e66ae14 100644 --- a/test/errors.test.js +++ b/test/errors.test.js @@ -20,7 +20,7 @@ describe('errors', () => { }); it('will report an error if given a reporter', function () { - const reporter = this.recurly.event; + const reporter = this.recurly.reporter; sinon.spy(reporter, 'send'); const err = errors(valid, { some: 'context' }, { reporter }); assert(reporter.send.calledOnce); diff --git a/test/pricing/checkout/checkout.test.js b/test/pricing/checkout/checkout.test.js index e30c4dc6a..43d6d17d5 100644 --- a/test/pricing/checkout/checkout.test.js +++ b/test/pricing/checkout/checkout.test.js @@ -216,6 +216,16 @@ describe('CheckoutPricing', function () { }); }); + it('passes the new adjustment which does not mutate pricing', function (done) { + const amount = 3.99; + this.pricing.adjustment({ amount }).then(adjustment => { + assert.equal(this.pricing.items.adjustments[0].amount, adjustment.amount); + adjustment.amount = 'spoofed'; + assert.equal(this.pricing.items.adjustments[0].amount, amount); + done(); + }); + }); + it('coerces quantity to an integer', function (done) { const examples = ['3', 3, 3.77, '3.97']; const part = after(examples.length, done); @@ -511,12 +521,47 @@ describe('CheckoutPricing', function () { describe('when given a valid coupon code', () => { const valid = 'coop'; - it('assigns the coupon and fires the set.coupon event', function (done) { - this.pricing.on('set.coupon', coupon => { - assert.equal(coupon.code, valid); - done(); + it('assigns the coupon and returns a version that does not mutate pricing', function (done) { + this.pricing.coupon(valid) + .then(coupon => { + assert.equal(coupon.code, valid); + coupon.code = 'spoofed'; + assert.equal(this.pricing.items.coupon.code, valid); + done(); + }) + .done(); + }); + + it('does not emit unset.coupon', function (done) { + const fail = () => { + assert.fail('unset.coupon emitted', 'unset.coupon should not be emitted'); + }; + this.pricing.on('error.coupon', fail); + this.pricing.on('unset.coupon', fail); + this.pricing.coupon(valid) + .then(() => setTimeout(done, 100)) + .done(); + }); + + describe('set.coupon event', () => { + it('emits and passes the new coupon', function (done) { + this.pricing.on('set.coupon', coupon => { + assert.equal(coupon.code, valid); + assert.equal(coupon.discount.amount.USD, 20.0); + done(); + }); + this.pricing.coupon(valid).done(); + }); + + it('passes the new coupon which cannot mutate pricing', function (done) { + this.pricing.on('set.coupon', coupon => { + assert.equal(coupon.code, valid); + coupon.code = 'spoofed'; + assert.equal(this.pricing.items.coupon.code, valid); + done(); + }); + this.pricing.coupon(valid).done(); }); - this.pricing.coupon(valid).done(); }); }); @@ -538,23 +583,54 @@ describe('CheckoutPricing', function () { }); describe('with a coupon already set', () => { + const valid = 'coop'; + beforeEach(function () { - return this.pricing.coupon('coop'); + return this.pricing.coupon(valid); }); - it(`accepts a blank coupon code and unsets the existing coupon, - firing the unset.coupon event`, function (done) { + it(`accepts a blank coupon code and unsets the existing coupon`, function (done) { const part = after(2, done); const errorSpy = sinon.spy(); - assert.equal(this.pricing.items.coupon.code, 'coop'); - this.pricing.on('unset.coupon', () => { - assert.equal(this.pricing.items.coupon, undefined); - part(); - }); + assert.equal(this.pricing.items.coupon.code, valid); this.pricing.on('error', errorSpy); - this.pricing.coupon().done(price => { - assert.equal(errorSpy.callCount, 0); - part(); + this.pricing.coupon() + .then(coupon => { + assert.equal(this.pricing.items.coupon, undefined); + part(); + }) + .done(price => { + assert.equal(errorSpy.callCount, 0); + part(); + }); + }); + + it('does nothing when the same coupon is set again', function (done) { + const currentCoupon = this.pricing.items.coupon; + const fail = event => { + assert.fail(`${event} emitted`, `${event} should not be emitted`); + }; + this.pricing.on('error.coupon', () => fail('error.coupon')); + this.pricing.on('set.coupon', () => fail('set.coupon')); + this.pricing.on('unset.coupon', () => fail('unset.coupon')); + this.pricing.coupon(valid) + .then(coupon => { + assert.equal(coupon.code, valid); + assert.equal(this.pricing.items.coupon.code, valid); + assert.equal(this.pricing.items.coupon, currentCoupon); + done(); + }) + .done(); + }); + + describe('unset.coupon event', function (done) { + it('emits and pasaes the prior coupon', function (done) { + this.pricing.on('unset.coupon', priorCoupon => { + assert.equal(priorCoupon.code, valid); + assert.equal(this.pricing.items.coupon, undefined); + done(); + }); + this.pricing.coupon().done(); }); }); }); @@ -1045,7 +1121,7 @@ describe('CheckoutPricing', function () { }); /** - * address - TODO + * currency */ describe('CheckoutPricing#currency', () => { @@ -1145,6 +1221,16 @@ describe('CheckoutPricing', function () { }); }); + it('passes the new gift card which does not mutate pricing', function (done) { + this.pricing.giftCard('super-gift-card').then(giftCard => { + assert.equal(this.pricing.items.giftCard.unit_amount, 20); + assert.equal(this.pricing.items.giftCard.unit_amount, giftCard.unit_amount); + giftCard.unit_amount = 'spoofed'; + assert.equal(this.pricing.items.giftCard.unit_amount, 20); + done(); + }); + }); + it('emits set.giftCard', function (done) { this.pricing.on('set.giftCard', giftCard => { assert.equal(giftCard.unit_amount, 20); @@ -1197,21 +1283,30 @@ describe('CheckoutPricing', function () { */ describe('CheckoutPricing#address', () => { + const valid = { country: 'US', postalCode: '94117', vatNumber: 'arbitrary' }; + it('Assigns address properties', function (done) { - const address = { country: 'US', postalCode: '94117', vatNumber: 'arbitrary' }; - this.pricing.address(address).done(() => { - assert.equal(this.pricing.items.address, address); + this.pricing.address(valid).done(() => { + assert.equal(this.pricing.items.address, valid); + done(); + }); + }); + + it('passes the new address which does not mutate pricing', function (done) { + this.pricing.address(valid).then(address => { + assert.equal(JSON.stringify(this.pricing.items.address), JSON.stringify(address)); + address.country = 'spoofed'; + assert.equal(this.pricing.items.address.country, valid.country); done(); }); }); it('Overwrites an existing address', function (done) { const part = after(2, done); - const address = { country: 'US', postalCode: '94117', vatNumber: 'arbitrary' }; this.pricing - .address(address) + .address(valid) .then(() => { - assert.equal(this.pricing.items.address, address); + assert.equal(this.pricing.items.address, valid); part(); }) .address({ country: 'DE', postalCode: 'DE-code' }) @@ -1229,21 +1324,30 @@ describe('CheckoutPricing', function () { */ describe('CheckoutPricing#shippingAddress', () => { + const valid = { country: 'US', postalCode: '94110', vatNumber: 'arbitrary-0' }; + it('Assigns address properties', function (done) { - const address = { country: 'US', postalCode: '94110', vatNumber: 'arbitrary-0' }; - this.pricing.shippingAddress(address).done(() => { - assert.equal(this.pricing.items.shippingAddress, address); + this.pricing.shippingAddress(valid).done(() => { + assert.equal(this.pricing.items.shippingAddress, valid); + done(); + }); + }); + + it('passes the new address which does not mutate pricing', function (done) { + this.pricing.shippingAddress(valid).then(address => { + assert.equal(JSON.stringify(this.pricing.items.shippingAddress), JSON.stringify(address)); + address.country = 'spoofed'; + assert.equal(this.pricing.items.shippingAddress.country, valid.country) done(); }); }); it('Overwrites an existing shipping address', function (done) { const part = after(2, done); - const address = { country: 'US', postalCode: '94117', vatNumber: 'arbitrary' }; this.pricing - .shippingAddress(address) + .shippingAddress(valid) .then(() => { - assert.equal(this.pricing.items.shippingAddress, address); + assert.equal(this.pricing.items.shippingAddress, valid); part(); }) .shippingAddress({ country: 'DE', postalCode: 'DE-code' }) diff --git a/test/pricing/subscription/subscription.test.js b/test/pricing/subscription/subscription.test.js index 9fedf5c25..94628fd59 100644 --- a/test/pricing/subscription/subscription.test.js +++ b/test/pricing/subscription/subscription.test.js @@ -274,7 +274,7 @@ describe('Recurly.Pricing.Subscription', function () { .giftcard('invalid'); }); - it('emits an event when the coupon is set', function (done) { + it('emits an event when the gift card is set', function (done) { this.pricing .on('set.gift_card', function (giftcard) { assert(giftcard.currency === 'USD'); @@ -288,7 +288,7 @@ describe('Recurly.Pricing.Subscription', function () { .giftcard('super-gift-card'); }); - it('emits an event when the coupon is unset', function (done) { + it('emits an event when the gift card is unset', function (done) { this.pricing .on('unset.gift_card', function () { done(); @@ -434,5 +434,30 @@ describe('Recurly.Pricing.Subscription', function () { done(); }); }); + + it('does nothing when the same coupon is set again', function (done) { + const fail = event => { + assert.fail(`${event} emitted`, `${event} should not be emitted`); + }; + this.pricing + .plan('basic', { quantity: 1 }) + .address({ + country: 'US', + postal_code: 'NoTax' + }) + .coupon('coop') + .then(() => { + this.pricing.on('error.coupon', () => fail('error.coupon')); + this.pricing.on('set.coupon', () => fail('set.coupon')); + this.pricing.on('unset.coupon', () => fail('unset.coupon')); + }) + .coupon('coop') + .then(coupon => { + assert.equal(coupon.code, 'coop'); + assert.equal(this.pricing.items.coupon.code, 'coop'); + done(); + }) + .done(); + }); }); }); diff --git a/test/server/index.js b/test/server/index.js index 6d10b9b27..9e41ced54 100644 --- a/test/server/index.js +++ b/test/server/index.js @@ -28,13 +28,13 @@ app.use(cors({ ejs(app, { root: __dirname, layout: false, viewExt: 'html.ejs' }); app.use(route.get('/coupons/:id', json)); +app.use(route.get('/events', ok)); app.use(route.post('/events', ok)); app.use(route.get('/fraud_data_collector', json)); app.use(route.get('/gift_cards/:id', json)); app.use(route.get('/plans/:plan_id', json)); app.use(route.get('/plans/:plan_id/coupons/:id', json)); app.use(route.get('/tax', json)); - app.use(route.get('/token', json)); app.use(route.post('/token', json));