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 ? `` : ''}