sales-tax
Version:
International sales tax calculator for Node (offline, but provides optional online VAT number fraud check). Tax rates are kept up-to-date.
635 lines (533 loc) • 17.7 kB
JavaScript
/*
* node-sales-tax
*
* Copyright 2017, Valerian Saliou
* Author: Valerian Saliou <valerian@valeriansaliou.name>
*/
"use strict";
var fetch = require('cross-fetch');
var check_fraud_eu_vat = require("validate-vat");
var validate_eu_vat = require("jsvat");
var validate_us_vat = require("ein-validator");
var regex_whitespace = /\s/g;
var regex_eu_vat = /^([A-Z]{2})(.+)$/;
var regex_gb_vat = /^GB([0-9]{9}([0-9]{3})?|[A-Z]{2}[0-9]{3})$/;
var regex_ca_vat = /^[0-9]{9}$/;
var validate_gb_vat_url = (
"https://api.service.hmrc.gov.uk/organisations/vat/check-vat-number/lookup"
);
var tax_rates = require("../res/sales_tax_rates.json");
var region_countries = require("../res/region_countries.json");
var tax_default_object = {
type : "none",
rate : 0.00,
currency : null
};
/**
* SalesTax
* @class
* @classdesc Instanciates a new sales tax object
*/
var SalesTax = function() {
this.taxOriginCountry = null;
this.useRegionalTax = true;
this.enabledTaxNumberValidation = true;
this.enabledTaxNumberFraudCheck = false;
};
/**
* SalesTax.prototype.hasSalesTax
* @public
* @param {string} countryCode
* @return {boolean} Whether country has sales tax
*/
SalesTax.prototype.hasSalesTax = function(
countryCode
) {
countryCode = (countryCode || "").toUpperCase();
return (
(((this.__readCurrentTaxRates(countryCode) || {}).rate || 0.00) > 0) ?
true : false
);
};
/**
* SalesTax.prototype.hasStateSalesTax
* @public
* @param {string} countryCode
* @param {string} stateCode
* @return {boolean} Whether country state has sales tax
*/
SalesTax.prototype.hasStateSalesTax = function(
countryCode, stateCode
) {
countryCode = (countryCode || "").toUpperCase();
stateCode = (stateCode || "").toUpperCase();
var stateTax = (
((this.__readCurrentTaxRates(countryCode) || {}).states || {})[stateCode]
);
return (
(((stateTax || {}).rate || 0.00) > 0) ? true : false
);
};
/**
* SalesTax.prototype.getSalesTax
* @public
* @param {string} countryCode
* @param {string} [stateCode]
* @param {string} [taxNumber]
* @return {object} Promise object (returns the sales tax from 0 to 1)
*/
SalesTax.prototype.getSalesTax = function(
countryCode, stateCode, taxNumber
) {
var self = this;
countryCode = (countryCode || "").toUpperCase();
stateCode = (stateCode || "").toUpperCase();
taxNumber = (taxNumber || null);
// Acquire target tax area
var targetArea = self.__getTargetArea(countryCode);
// Acquire sales tax for country, or default (if no known sales tax)
// Notice: if regional tax is ignored, force national tax \
// (eg. EU w/o VAT MOSS)
var countryTax, stateTax;
if (targetArea === "regional" && this.useRegionalTax === false &&
this.taxOriginCountry !== null) {
countryTax = (
this.__readCurrentTaxRates(this.taxOriginCountry) || tax_default_object
);
stateTax = tax_default_object;
} else {
countryTax = (
this.__readCurrentTaxRates(countryCode) || tax_default_object
);
stateTax = ((countryTax.states || {})[stateCode] || tax_default_object);
}
if (countryTax.rate > 0 || stateTax.rate > 0) {
return self.getTaxExchangeStatus(countryCode, stateCode, taxNumber)
.then(function(exchangeStatus) {
return Promise.resolve(
self.__buildSalesTaxContext(countryTax, stateTax, exchangeStatus)
);
});
}
return Promise.resolve(
self.__buildSalesTaxContext(countryTax, stateTax)
);
};
/**
* SalesTax.prototype.getAmountWithSalesTax
* @public
* @param {string} countryCode
* @param {string} [stateCode]
* @param {number} [amount]
* @param {string} [taxNumber]
* @return {object} Promise object (returns the total tax amount)
*/
SalesTax.prototype.getAmountWithSalesTax = function(
countryCode, stateCode, amount, taxNumber
) {
var self = this;
countryCode = (countryCode || "").toUpperCase();
stateCode = (stateCode || "").toUpperCase();
amount = (amount || 0.00);
taxNumber = (taxNumber || null);
// Acquire sales tax, then process amount.
return self.getSalesTax(countryCode, stateCode, taxNumber)
.then(function(tax) {
// Generate amount details (list of all sub-amounts from each sub-tax \
// rate)
var amountDetails = [];
for (var i = 0; i < tax.details.length; i++) {
amountDetails.push({
type : tax.details[i].type,
rate : tax.details[i].rate,
amount : tax.details[i].rate * amount
});
}
// Return total amount with sales tax
return Promise.resolve({
type : tax.type,
rate : tax.rate,
currency : tax.currency,
price : amount,
total : (1.00 + tax.rate) * amount,
area : tax.area,
exchange : tax.exchange,
charge : tax.charge,
details : amountDetails
});
});
};
/**
* SalesTax.prototype.validateTaxNumber
* @public
* @param {string} countryCode
* @param {string} taxNumber
* @return {object} Promise object (returns a boolean for validity)
*/
SalesTax.prototype.validateTaxNumber = function(
countryCode, taxNumber
) {
var self = this;
countryCode = (countryCode || "").toUpperCase();
if (self.enabledTaxNumberValidation === true) {
// Normalize tax number (eg. remove spaces)
var cleanTaxNumber = (taxNumber || "").replace(regex_whitespace, "");
if (cleanTaxNumber) {
// United States
if (countryCode === "US") {
// Validate US EIN
return Promise.resolve(
validate_us_vat.isValid(cleanTaxNumber) && true
);
}
// Canada
if (countryCode === "CA") {
// Validate CA BN
return Promise.resolve(
regex_ca_vat.test(cleanTaxNumber) && true
);
}
// United Kingdom
if (countryCode === "GB") {
// Validate GB VAT number
var splitMatch = cleanTaxNumber.match(regex_gb_vat);
var isValid = ((splitMatch && splitMatch[1]) ? true : false);
// Check number for fraud? (online check)
if (isValid === true && self.enabledTaxNumberFraudCheck === true) {
// Query UK HMRC validation API
return fetch(
validate_gb_vat_url + "/" + splitMatch[1]
)
.then(function(response) {
return Promise.resolve(
(response.status >= 200 && response.status <= 299) ?
true : false
);
})
.catch(function(error) {
return Promise.reject(error);
});
}
return Promise.resolve(isValid);
}
// European Union member states (sourced from dynamic list)
if ((region_countries.EU || []).indexOf(countryCode) !== -1) {
return new Promise(function(resolve, reject) {
// Validate EU VAT number (offline check)
var validationInfo = validate_eu_vat.checkVAT(
cleanTaxNumber, validate_eu_vat.countries
);
// Check if VAT number is valid
var isValid = (validationInfo.isValid && true);
// No country match?
if (isValid === true &&
((validationInfo.country || {}).isoCode || {}).short !==
countryCode) {
isValid = false;
}
// Check number for fraud? (online check)
if (isValid === true && self.enabledTaxNumberFraudCheck === true) {
// Split VAT number (n extract actual VAT number)
var splitMatch = cleanTaxNumber.match(regex_eu_vat);
// Check fraud on EU VAT number?
// Notice: use country code from VAT number during the check, \
// since some country codes are different there, eg. Greece. It \
// is still deemed safe to use the country code from the VAT \
// number, since we performed an offline check of the country \
// code match with the provided country code right before.
if (splitMatch && splitMatch[1] && splitMatch[2]) {
check_fraud_eu_vat(
splitMatch[1], splitMatch[2],
function(error, fraudInfo) {
if (error) {
return reject(error);
}
// Return whether valid or not
return resolve(fraudInfo.valid && true);
}
);
} else {
return resolve(false);
}
} else {
return resolve(isValid);
}
});
}
}
// Consider as invalid tax number (tax number country not recognized, \
// or no tax number provided)
return Promise.resolve(false);
}
// Consider all tax numbers as valid
return Promise.resolve(true);
};
/**
* SalesTax.prototype.getTaxExchangeStatus
* @public
* @param {string} countryCode
* @param {string} [stateCode]
* @param {string} [taxNumber]
* @return {object} Promise object (returns an exchange status object)
*/
SalesTax.prototype.getTaxExchangeStatus = function(
countryCode, stateCode, taxNumber
) {
var self = this;
countryCode = (countryCode || "").toUpperCase();
stateCode = ((stateCode || "").toUpperCase() || null);
taxNumber = (taxNumber || null);
var targetArea = self.__getTargetArea(countryCode);
// Country or state (if any) has any sales tax?
if (self.__hasTotalSalesTax(countryCode, stateCode) === true) {
// Check for tax-exempt status? (if tax number is provided)
if (taxNumber) {
return self.validateTaxNumber(countryCode, taxNumber)
.then(function(isValid) {
// Consider valid numbers as tax-exempt (overrides exempt status if \
// area is national)
if (isValid === true) {
return Promise.resolve({
exchange : "business",
area : targetArea,
exempt : (targetArea !== "national" && true)
});
}
return Promise.resolve({
exchange : "consumer",
area : targetArea,
exempt : false
});
});
}
// Consider as non tax-exempt
return Promise.resolve({
exchange : "consumer",
area : targetArea,
exempt : false
});
}
// Consider as tax-exempt (country has no sales tax)
return Promise.resolve({
exchange : "consumer",
area : targetArea,
exempt : true
});
};
/**
* SalesTax.prototype.setTaxOriginCountry
* @public
* @param {string} countryCode
* @return {undefined}
*/
SalesTax.prototype.setTaxOriginCountry = function(
countryCode, useRegionalTax
) {
this.taxOriginCountry = ((countryCode || "").toUpperCase() || null);
if (typeof useRegionalTax === "boolean") {
this.useRegionalTax = useRegionalTax;
}
};
/**
* SalesTax.prototype.toggleEnabledTaxNumberValidation
* @public
* @param {boolean} enabled
* @return {undefined}
*/
SalesTax.prototype.toggleEnabledTaxNumberValidation = function(
enabled
) {
this.enabledTaxNumberValidation = (enabled && true);
};
/**
* SalesTax.prototype.toggleEnabledTaxNumberFraudCheck
* @public
* @param {boolean} enabled
* @return {undefined}
*/
SalesTax.prototype.toggleEnabledTaxNumberFraudCheck = function(
enabled
) {
this.enabledTaxNumberFraudCheck = (enabled && true);
};
/**
* SalesTax.prototype.__getTargetArea
* @private
* @param {string} countryCode
* @return {string} Target area
*/
SalesTax.prototype.__getTargetArea = function(
countryCode
) {
// Default to worldwide
var targetArea = "worldwide";
if (this.taxOriginCountry !== null) {
if (this.taxOriginCountry === countryCode) {
// Same country (national)
targetArea = "national";
} else {
// Same economic community? (regional)
for (var region in region_countries) {
var regionCountries = region_countries[region];
if (regionCountries.indexOf(this.taxOriginCountry) !== -1 &&
regionCountries.indexOf(countryCode) !== -1) {
targetArea = "regional";
break;
}
}
}
}
return targetArea;
};
/**
* SalesTax.prototype.__buildSalesTaxContext
* @private
* @param {object} countryTax
* @param {object} stateTax
* @param {object} [exchangeStatus]
* @return {object} Sales tax context object
*/
SalesTax.prototype.__buildSalesTaxContext = function(
countryTax, stateTax, exchangeStatus
) {
// Acquire exchange + exempt + area
var taxExchange = ((exchangeStatus || {}).exchange || "consumer");
var taxArea = ((exchangeStatus || {}).area || "worldwide");
var isExempt = ((exchangeStatus || {}).exempt || false);
// Compute full tax rate (country + state)
// Notice: convert the result to a fixed value, then parse it again as a \
// floating point number to correct floating point approximations due to \
// the JS VM. This fixes eg. (0.21 - 0.14) = 0.06999999999999998 <> O.07 \
// miscalculations. We support up to 6 floating point decimals here.
var fullRate = parseFloat((countryTax.rate + stateTax.rate).toFixed(6));
// Generate tax type (multiple sales tax may apply, eg. country + state)
// Notice: replace the country tax type with the state tax type in the event \
// that the state tax is substractive and not additive.
var type = countryTax.type;
if (stateTax.rate > 0) {
if (countryTax.rate > 0) {
if (type !== stateTax.type) {
type = type + "+" + stateTax.type;
}
} else {
type = stateTax.type;
}
} else if (stateTax.rate < 0) {
type = stateTax.type;
}
// Build charge object
var taxCharge = {};
if (type !== "none") {
taxCharge.direct = !isExempt;
taxCharge.reverse = (isExempt && fullRate > 0 && true);
} else {
taxCharge.direct = false;
taxCharge.reverse = false;
}
// Build details object (list of all sub-taxes that make up the total rate)
var taxDetails = [];
if (isExempt !== true) {
if (countryTax.rate > 0) {
taxDetails.push({
type : countryTax.type,
rate : countryTax.rate
});
}
if (stateTax.rate > 0) {
taxDetails.push({
type : stateTax.type,
rate : stateTax.rate
});
}
}
// Build sales tax context
return {
type : type,
rate : (isExempt === true) ? 0.00 : fullRate,
currency : (countryTax.currency || null),
area : taxArea,
exchange : taxExchange,
charge : taxCharge,
details : taxDetails
};
};
/**
* SalesTax.prototype.__readCurrentTaxRates
* @private
* @param {object} countryCode
* @return {object} Current tax rates
*/
SalesTax.prototype.__readCurrentTaxRates = function(
countryCode
) {
var countryTaxRates = tax_rates[countryCode];
// Check tax rates before date? (if any defined)
if (typeof (countryTaxRates || {}).before === "object") {
var nowDate = this.__getCurrentDate();
var lowestBeforeDateString = null;
var lowestBeforeDate = null;
for (var beforeDateString in countryTaxRates.before) {
// Parse date string to an actual date object
// Notice: the date string uses an UTC+00:00 timezone, which gets parsed \
// into the runtime-local timezone, and compared against current date.
var beforeDate = (new Date(beforeDateString));
// Date is invalid? Throw an error, as we need to be clear that the \
// tax rates object is invalid.
if (isNaN(beforeDate.getTime()) === true) {
throw new Error("Invalid 'before' date string: " + beforeDateString);
}
// Are we still in the past relative to this country sales tax \
// rates? Then, pickup this tax rate (until we get past-date, and \
// then we can use the latest/master tax rate)
if (nowDate < beforeDate) {
// New lowest before date? (or none previously-defined)
// Notice: pick the lowest found before date, as multiple 'before' \
// dates can be defined as to schedule multiple tax rate changes in \
// the future. Thus, we do not want to pick the first 'before' match.
if (lowestBeforeDate === null || beforeDate < lowestBeforeDate) {
lowestBeforeDate = beforeDate;
lowestBeforeDateString = beforeDateString;
}
}
}
if (lowestBeforeDateString !== null) {
return countryTaxRates.before[lowestBeforeDateString];
}
}
return countryTaxRates;
};
/**
* SalesTax.prototype.__hasTotalSalesTax
* @private
* @param {string} countryCode
* @param {string} stateCode
* @return {boolean} Whether country and state added result in a tax or not
*/
SalesTax.prototype.__hasTotalSalesTax = function(
countryCode, stateCode
) {
countryCode = (countryCode || "").toUpperCase();
stateCode = (stateCode || "").toUpperCase();
var taxRates = (this.__readCurrentTaxRates(countryCode) || {});
var countryTax = (taxRates.rate || 0.00);
var stateTax = 0.0;
if (stateCode) {
stateTax = (((taxRates.states || {})[stateCode] || {}).rate || 0.00);
}
return (
((countryTax + stateTax) > 0) ? true : false
);
};
/**
* SalesTax.prototype.__getCurrentDate
* @private
* @return {object} Current date
*/
SalesTax.prototype.__getCurrentDate = function() {
// Return current date
// Notice: this function is useless as-is, though it comes handy when \
// unit-testing the library, as it lets us override current date with \
// an hardcoded date, eg. to test the automated sales tax change feature.
return (new Date());
};
module.exports = new SalesTax();