diff --git a/lib/recurly/pricing/checkout/attachment.js b/lib/recurly/pricing/checkout/attachment.js index a27002ffb..10cd04c58 100644 --- a/lib/recurly/pricing/checkout/attachment.js +++ b/lib/recurly/pricing/checkout/attachment.js @@ -179,8 +179,16 @@ export default class Attachment extends Emitter { } }) .then(() => { - if (!elems.vat_number) return; - return this.pricing.tax({ vat_number: dom.value(elems.vat_number) }); + // Taxes + let taxParams = {}; + if (elems['tax_amount.now'] || elems['tax_amount.next']) { + taxParams.amount = { + now: (dom.value(elems['tax_amount.now']) || 0), + next: (dom.value(elems['tax_amount.next']) || 0) + } + } + if (elems.vat_number) taxParams.vat_number = dom.value(elems.vat_number); + return this.pricing.tax(taxParams); }) .then(() => { return this.pricing.reprice(); diff --git a/lib/recurly/pricing/checkout/calculations.js b/lib/recurly/pricing/checkout/calculations.js index b04ed3e8c..edf18ac9b 100644 --- a/lib/recurly/pricing/checkout/calculations.js +++ b/lib/recurly/pricing/checkout/calculations.js @@ -182,6 +182,13 @@ export default class Calculations { let taxNow = this.price.now.taxes = 0; let taxNext = this.price.next.taxes = 0; + // If tax amount has been specified, simply apply it + if (this.items.tax && this.items.tax.amount) { + this.price.now.taxes = taxCeil(this.items.tax.amount.now); + this.price.next.taxes = taxCeil(this.items.tax.amount.next); + return Promise.resolve(); + } + const taxAddress = this.items.shippingAddress || this.items.address; const baseTaxInfo = Object.assign({}, taxAddress, this.items.tax); diff --git a/lib/recurly/pricing/checkout/index.js b/lib/recurly/pricing/checkout/index.js index df98bc588..b4c539591 100644 --- a/lib/recurly/pricing/checkout/index.js +++ b/lib/recurly/pricing/checkout/index.js @@ -313,9 +313,13 @@ export default class CheckoutPricing extends Pricing { * * @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); } diff --git a/lib/recurly/pricing/index.js b/lib/recurly/pricing/index.js index 09d175f5d..4685ecf99 100644 --- a/lib/recurly/pricing/index.js +++ b/lib/recurly/pricing/index.js @@ -3,6 +3,7 @@ import Emitter from 'component-emitter'; import clone from 'component-clone'; import find from 'component-find'; import pick from 'lodash.pick'; +import isFinite from 'lodash.isfinite'; import PricingPromise from './promise'; import errors from '../errors'; import decimalize from '../../util/decimalize' @@ -131,6 +132,23 @@ export class Pricing extends Emitter { }, this).nodeify(done); } + /** + * Checks tax params against expectations + * + * @param {Object} tax + * @throws {String} error message if expectations are not met + */ + guardTaxSignature (tax = {}) { + if (!tax.amount) return; + if (typeof tax.amount !== 'object') { + throw `Invalid 'amount'. Expected 'Object', got '${typeof tax.amount}'`; + } else if (!isFinite(+tax.amount.now)) { + throw `Invalid 'amount.now'. Expected 'Finite Numeric', got '${typeof tax.amount.now}'`; + } else if (!isFinite(+tax.amount.next)) { + throw `Invalid 'amount.next'. Expected 'Finite Numeric', got '${typeof tax.amount.next}'`; + } + } + /** * Utility to emit an event and call a PricingPromise resolver * with a mutation-safe copy of the item object diff --git a/lib/recurly/pricing/subscription/attachment.js b/lib/recurly/pricing/subscription/attachment.js index a8f750bb3..4ff792f03 100644 --- a/lib/recurly/pricing/subscription/attachment.js +++ b/lib/recurly/pricing/subscription/attachment.js @@ -67,7 +67,7 @@ export default class Attachment extends Emitter { const updateCoupon = elems.coupon && (updating('coupon') || updating('plan')); const updateGiftcard = elems.gift_card && updating('gift_card'); const updateShippingAddress = updating('shipping_address.country') || updating('shipping_address.postal_code'); - const updateTax = updating('vat_number') || updating('tax_code'); + const updateTax = ['vat_number', 'tax_code', 'tax_amount.now', 'tax_amount.next'].some(updating); let pricing = this.pricing.plan(dom.value(elems.plan), { quantity: dom.value(elems.plan_quantity) }); @@ -112,10 +112,17 @@ export default class Attachment extends Emitter { } if (updateTax) { - pricing = pricing.tax({ - vat_number: dom.value(elems.vat_number), - tax_code: dom.value(elems.tax_code) - }); + let taxParams = { + vatNumber: dom.value(elems.vat_number), + taxCode: dom.value(elems.tax_code) + }; + if (elems['tax_amount.now'] || elems['tax_amount.next']) { + taxParams.amount = { + now: (dom.value(elems['tax_amount.now']) || 0), + next: (dom.value(elems['tax_amount.next']) || 0) + } + } + pricing = pricing.tax(taxParams); } this.pricing = pricing.done(() => event === INIT_RUN && this.emit('ready')); diff --git a/lib/recurly/pricing/subscription/calculations.js b/lib/recurly/pricing/subscription/calculations.js index e36dea59e..8efb6f9cc 100644 --- a/lib/recurly/pricing/subscription/calculations.js +++ b/lib/recurly/pricing/subscription/calculations.js @@ -92,6 +92,13 @@ export default class Calculations { this.price.now.tax = 0; this.price.next.tax = 0; + // If tax amount has been specified, simply apply it + if (this.items.tax && this.items.tax.amount) { + this.price.now.tax = taxCeil(this.items.tax.amount.now); + this.price.next.tax = taxCeil(this.items.tax.amount.next); + return done.call(this); + } + // Tax the shipping address if present, or const taxAddress = this.items.shipping_address || this.items.address; const taxInfo = Object.assign({}, taxAddress, this.items.tax); diff --git a/lib/recurly/pricing/subscription/index.js b/lib/recurly/pricing/subscription/index.js index 5a9b6b0cd..bfb6bf6b8 100644 --- a/lib/recurly/pricing/subscription/index.js +++ b/lib/recurly/pricing/subscription/index.js @@ -285,12 +285,16 @@ export default class SubscriptionPricing extends Pricing { * @param {Object} tax * @param {String} tax.taxCode * @param {String} tax.vatNmber + * @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] specific tax to apply on the next billing cycle * @param {String} tax.tax_code // deprecated * @param {String} tax.vat_number // deprecated * @param {Function} [done] callback * @public */ tax (tax, done) { + this.guardTaxSignature(tax); return new PricingPromise(this.itemUpdateFactory('tax', tax), this).nodeify(done); } diff --git a/test/apple-pay.test.js b/test/apple-pay.test.js index 087347a8b..1a3d443ff 100644 --- a/test/apple-pay.test.js +++ b/test/apple-pay.test.js @@ -1,5 +1,6 @@ import assert from 'assert'; import clone from 'component-clone'; +import find from 'component-find'; import merge from 'lodash.merge'; import omit from 'lodash.omit'; import Emitter from 'component-emitter'; @@ -199,6 +200,17 @@ apiTest(function (requestMethod) { assert.strictEqual(this.applePay.totalLineItem.amount, '1.00'); }); }); + + describe('when tax amounts are specified', () => { + beforeEach(function (done) { + this.pricing.tax({ amount: { now: 20.01, next: 18.46 } }).done(() => done()); + }); + + it('sets the tax line item accordingly', function () { + const taxLineItem = find(this.applePay.lineItems, li => li.label === this.applePay.config.i18n.taxLineItemLabel); + assert.strictEqual(taxLineItem.amount, '20.01'); + }); + }); }); }); diff --git a/test/pricing/checkout/attachment.test.js b/test/pricing/checkout/attachment.test.js index 80fbd3cd1..197f478ce 100644 --- a/test/pricing/checkout/attachment.test.js +++ b/test/pricing/checkout/attachment.test.js @@ -118,17 +118,15 @@ describe('CheckoutPricing#attach', function () { }); }); - describe('when given multiple subscriptions and adjustments', () => { - beforeEach(function () { - this.currentTest.ctx.fixture = 'checkoutPricing'; - this.currentTest.ctx.fixtureOpts = { - sub_0_plan: 'basic', - sub_1_plan: 'basic-2', - adj_0: '1', - adj_1: '3', - giftcard: 'super-gift-card' - }; - }); + describe('when given multiple subscriptions and adjustments', function () { + this.ctx.fixture = 'checkoutPricing'; + this.ctx.fixtureOpts = { + sub_0_plan: 'basic', + sub_1_plan: 'basic-2', + adj_0: '1', + adj_1: '3', + giftcard: 'super-gift-card' + }; applyFixtures(); @@ -175,5 +173,74 @@ describe('CheckoutPricing#attach', function () { done(); }); }); + + describe('when tax amounts are set', function () { + describe('when tax amounts are blank', function () { + this.ctx.fixtureOpts = { + sub_0_plan: 'basic', + sub_1_plan: 'basic-2', + adj_0: '1', + adj_1: '3', + 'tax_amount.now': '', + 'tax_amount.next': '' + }; + + it('set the amounts to zero', function (done) { + this.pricing.on('set.tax', () => { + container(); + assert.strictEqual(this.pricing.items.tax.amount.now, 0); + assert.strictEqual(this.pricing.items.tax.amount.next, 0); + done(); + }); + }); + }); + + describe('when only setting tax_amount.now', function () { + this.ctx.fixtureOpts = { + sub_0_plan: 'basic', + sub_1_plan: 'basic-2', + adj_0: '1', + adj_1: '3', + 'tax_amount.now': '20', + }; + + it('sets the `tax_amount.now` and defaults the `tax_amount.next` to zero', function (done) { + this.pricing.on('set.tax', () => { + assert.strictEqual(this.pricing.items.tax.amount.now, '20'); + assert.strictEqual(this.pricing.items.tax.amount.next, 0); + done(); + }); + }); + }); + + describe('when setting both tax amounts', function () { + this.ctx.fixtureOpts = { + sub_0_plan: 'basic', + sub_1_plan: 'basic-2', + adj_0: '1', + adj_1: '3', + 'tax_amount.now': '20', + 'tax_amount.next': '10' + }; + + it('sets both values', function (done) { + this.pricing.on('set.tax', () => { + assert.strictEqual(this.pricing.items.tax.amount.now, '20'); + assert.strictEqual(this.pricing.items.tax.amount.next, '10'); + done(); + }); + }); + + it('outputs the tax amounts exactly as given', function (done) { + this.pricing.on('change', () => { + // HACK: await application of taxes + if (!this.pricing.items.tax) return; + assert.strictEqual(container().querySelector('[data-recurly=taxes_now]').innerHTML, '20.00'); + assert.strictEqual(container().querySelector('[data-recurly=taxes_next]').innerHTML, '10.00'); + done(); + }); + }); + }); + }); }); }); diff --git a/test/pricing/checkout/checkout.test.js b/test/pricing/checkout/checkout.test.js index 248eef5d8..923056918 100644 --- a/test/pricing/checkout/checkout.test.js +++ b/test/pricing/checkout/checkout.test.js @@ -1573,7 +1573,6 @@ describe('CheckoutPricing', function () { this.pricing .reprice() .done(price => { - this.pricing; // 8.75% of taxable amount: 21.99 (sub) + 40 (adj) - 9 (discount) = 52.99 assert.equal(price.now.taxes, '4.64'); // 8.75% of taxable amount: 19.99 (sub) - 3 (discount) = 16.99 @@ -1583,6 +1582,32 @@ describe('CheckoutPricing', function () { }); }); }); + + describe('given specific tax amounts', () => { + it('requires the now and next amounts be given as finite numbers', function () { + assert.throws(() => this.pricing.tax({ amount: 'invalid' }), /Invalid 'amount'/); + assert.throws(() => this.pricing.tax({ amount: { now: 'invalid' } }), /Invalid 'amount.now'/); + assert.throws(() => this.pricing.tax({ amount: { now: 20 } }), /Invalid 'amount.next'/); + assert.throws(() => this.pricing.tax({ amount: { now: 20, next: 'invalid' } }), /Invalid 'amount.next'/); + }); + + it('applies the specific tax amounts as provided', function (done) { + this.pricing + .tax({ + amount: { + now: 20, + next: 10 + } + }) + .done(price => { + assert.strictEqual(this.pricing.items.tax.amount.now, 20); + assert.strictEqual(this.pricing.items.tax.amount.next, 10); + assert.strictEqual(price.now.taxes, '20.00'); + assert.strictEqual(price.next.taxes, '10.00'); + done(); + }); + }); + }); }); }); }); diff --git a/test/pricing/subscription/attachment.test.js b/test/pricing/subscription/attachment.test.js index 2a30daa99..6ab166ab9 100644 --- a/test/pricing/subscription/attachment.test.js +++ b/test/pricing/subscription/attachment.test.js @@ -101,7 +101,7 @@ describe('Recurly.Pricing.attach', function () { }) }); - describe('when pre-populated with a valid giftcard redemption code', function () { + describe('when pre-populated with a valid gift card redemption code', function () { this.ctx.fixtureOpts = { plan: 'basic', giftcard: 'super-gift-card' @@ -116,5 +116,62 @@ describe('Recurly.Pricing.attach', function () { }); }); }); + + describe('when tax amounts are set', function () { + describe('when tax amounts are blank', function () { + this.ctx.fixtureOpts = { + plan: 'basic', + 'tax_amount.now': '', + 'tax_amount.next': '' + }; + + it('set the amounts to zero', function (done) { + this.pricing.on('set.tax', () => { + assert.strictEqual(this.pricing.items.tax.amount.now, 0); + assert.strictEqual(this.pricing.items.tax.amount.next, 0); + done(); + }); + }); + }); + + describe('when only setting tax_amount.now', function () { + this.ctx.fixtureOpts = { + plan: 'basic', + 'tax_amount.now': '20' + }; + + it('sets the `tax_amount.now` and defaults the `tax_amount.next` to zero', function (done) { + this.pricing.on('set.tax', () => { + assert.strictEqual(this.pricing.items.tax.amount.now, '20'); + assert.strictEqual(this.pricing.items.tax.amount.next, 0); + done(); + }); + }); + }); + + describe('when setting both tax amounts', function () { + this.ctx.fixtureOpts = { + plan: 'basic', + 'tax_amount.now': '20', + 'tax_amount.next': '10' + }; + + it('sets both values', function (done) { + this.pricing.on('set.tax', () => { + assert.strictEqual(this.pricing.items.tax.amount.now, '20'); + assert.strictEqual(this.pricing.items.tax.amount.next, '10'); + done(); + }); + }); + + it('outputs the tax amounts exactly as given', function (done) { + this.pricing.on('change', () => { + assert.strictEqual(container().querySelector('[data-recurly=tax_now]').innerHTML, '20.00'); + assert.strictEqual(container().querySelector('[data-recurly=tax_next]').innerHTML, '10.00'); + done(); + }); + }); + }); + }); }); }); diff --git a/test/pricing/subscription/subscription.test.js b/test/pricing/subscription/subscription.test.js index b93959f46..5bd83ad5d 100644 --- a/test/pricing/subscription/subscription.test.js +++ b/test/pricing/subscription/subscription.test.js @@ -178,6 +178,37 @@ describe('Recurly.Pricing.Subscription', function () { }); }) }); + + describe('given specific tax amounts', () => { + it('requires the now and next amounts be given as finite numbers', function () { + assert.throws(() => this.pricing.tax({ amount: 'invalid' }), /Invalid 'amount'/); + assert.throws(() => this.pricing.tax({ amount: { now: 'invalid' } }), /Invalid 'amount.now'/); + assert.throws(() => this.pricing.tax({ amount: { now: 20 } }), /Invalid 'amount.next'/); + assert.throws(() => this.pricing.tax({ amount: { now: 20, next: 'invalid' } }), /Invalid 'amount.next'/); + }); + + it('applies the specific tax amounts as provided, ignoring built-in tax calculations', function (done) { + this.pricing + .plan('basic', { quantity: 1 }) + .address({ + country: 'US', + postal_code: '94129' + }) + .tax({ + amount: { + now: 20, + next: 10 + } + }) + .done(price => { + assert.strictEqual(this.pricing.items.tax.amount.now, 20); + assert.strictEqual(this.pricing.items.tax.amount.next, 10); + assert.strictEqual(price.now.tax, '20.00'); + assert.strictEqual(price.next.tax, '10.00'); + done(); + }); + }); + }); }); describe('with addons', () => { diff --git a/test/support/fixtures.js b/test/support/fixtures.js index 0d2ff6f07..981ef6f74 100644 --- a/test/support/fixtures.js +++ b/test/support/fixtures.js @@ -64,6 +64,8 @@ const pricing = opts => ` + ${'tax_amount.now' in opts ? `` : ''} + ${'tax_amount.next' in opts ? `` : ''} ${opts['shipping_address.country'] ? `` : '' } ${opts['shipping_address.postal_code'] ? `` : '' } @@ -117,6 +119,8 @@ const checkoutPricing = opts => ` + ${'tax_amount.now' in opts ? `` : ''} + ${'tax_amount.next' in opts ? `` : ''}