Skip to content

Commit

Permalink
Merge pull request #515 from recurly/pricing-custom-tax-amount
Browse files Browse the repository at this point in the history
Adds specific tax amount overrides to SubscriptionPricing and CheckoutPricing
  • Loading branch information
Aaron Suarez authored Mar 18, 2019
2 parents 5281405 + 51bc103 commit 00d6a3b
Show file tree
Hide file tree
Showing 13 changed files with 271 additions and 20 deletions.
12 changes: 10 additions & 2 deletions lib/recurly/pricing/checkout/attachment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
7 changes: 7 additions & 0 deletions lib/recurly/pricing/checkout/calculations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
4 changes: 4 additions & 0 deletions lib/recurly/pricing/checkout/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
18 changes: 18 additions & 0 deletions lib/recurly/pricing/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions lib/recurly/pricing/subscription/attachment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) });

Expand Down Expand Up @@ -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'));
Expand Down
7 changes: 7 additions & 0 deletions lib/recurly/pricing/subscription/calculations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions lib/recurly/pricing/subscription/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
12 changes: 12 additions & 0 deletions test/apple-pay.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
});
});
});
});

Expand Down
89 changes: 78 additions & 11 deletions test/pricing/checkout/attachment.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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();
});
});
});
});
});
});
27 changes: 26 additions & 1 deletion test/pricing/checkout/checkout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
});
});
});
});
});
});
Expand Down
59 changes: 58 additions & 1 deletion test/pricing/subscription/attachment.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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();
});
});
});
});
});
});
Loading

0 comments on commit 00d6a3b

Please sign in to comment.