dutch-tax-income-calculator
Version:
NPM package with functionality to calculate Dutch Tax Income
622 lines (576 loc) • 25.3 kB
JavaScript
import constants from './data.json' with { type: 'json' };
class SalaryPaycheck {
/**
* For calculation instructions:
* https://www.belastingdienst.nl/wps/wcm/connect/nl/zoeken/zoeken?q=Rekenvoorschriften+voor+de+geautomatiseerde+loonadministratie
*
* @param {object} salaryInput Salary input information
* @param {'Year'|'Month'|'Week'|'Day'|'Hour'} startFrom Salary input information
* @param {number} year Year to perform calculation
* @param {object} ruling Salary input information
* @returns {object} Object with all calculated fields for the salary paycheck
*/
constructor(salaryInput, startFrom, year, ruling) {
let { income, allowance, socialSecurity, older, hours } = salaryInput;
this.grossYear =
this.grossMonth =
this.grossWeek =
this.grossDay =
this.grossHour =
0;
this['gross' + startFrom] = income;
let grossYear =
this.grossYear +
this.grossMonth * 12 +
this.grossWeek * constants.workingWeeks;
grossYear +=
this.grossDay * constants.workingDays +
this.grossHour * constants.workingWeeks * hours;
if (!grossYear || grossYear < 0) {
grossYear = 0;
}
// Store the original input before any adjustments
this.inputGrossYear = roundNumber(grossYear, 2);
// When salary doesn't include holiday allowance but 30% ruling is applied,
// add 8% to get total employment income (ruling applies to total comp)
let grossInflated = false;
if (!allowance && ruling.checked) {
grossYear = roundNumber(grossYear * 1.08, 2);
allowance = true;
grossInflated = true;
}
this.taxFreeYear = 0;
if (ruling.checked) {
let rulingIncome = SalaryPaycheck.getRulingIncome(year, ruling.choice);
let rulingMaxSalary = constants.rulingMaxSalary[year];
// 30% ruling applies to full gross (including holiday allowance)
let salaryEligibleForRuling = Math.min(grossYear, rulingMaxSalary);
let salaryAboveCap = Math.max(0, grossYear - rulingMaxSalary);
// Calculate the 30% on eligible salary only
let effectiveSalary = salaryEligibleForRuling * 0.7 + salaryAboveCap;
effectiveSalary = Math.max(effectiveSalary, rulingIncome);
let reimbursement = grossYear - effectiveSalary;
if (reimbursement > 0) {
this.taxFreeYear = reimbursement;
} else if (grossInflated) {
// Ruling doesn't apply — revert the 8% inflation
grossYear = this.inputGrossYear;
allowance = false;
grossInflated = false;
}
}
this.grossAllowance = allowance
? SalaryPaycheck.getHolidayAllowance(grossYear)
: 0;
this.grossYear = roundNumber(grossYear, 2);
this.grossMonth = SalaryPaycheck.getAmountMonth(grossYear);
this.grossWeek = SalaryPaycheck.getAmountWeek(grossYear);
this.grossDay = SalaryPaycheck.getAmountDay(grossYear);
this.grossHour = SalaryPaycheck.getAmountHour(grossYear, hours);
this.taxableYear =
this.taxFreeYear > 0
? grossYear - this.taxFreeYear
: grossYear - this.grossAllowance;
this.taxFreeYear = roundNumber(this.taxFreeYear, 2);
this.taxFree = SalaryPaycheck.getTaxFree(this.taxFreeYear, grossYear);
this.taxableYear = roundNumber(this.taxableYear, 2);
this.payrollTax = -1 * SalaryPaycheck.getPayrollTax(year, this.taxableYear);
this.payrollTaxMonth = SalaryPaycheck.getAmountMonth(this.payrollTax);
this.socialTax = socialSecurity
? -1 * SalaryPaycheck.getSocialTax(year, this.taxableYear, older)
: 0;
this.socialTaxMonth = SalaryPaycheck.getAmountMonth(this.socialTax);
this.taxWithoutCredit = roundNumber(this.payrollTax + this.socialTax, 2);
this.taxWithoutCreditMonth = SalaryPaycheck.getAmountMonth(
this.taxWithoutCredit
);
let socialCredit = SalaryPaycheck.getSocialCredit(
year,
older,
socialSecurity
);
this.labourCredit = SalaryPaycheck.getLabourCredit(
year,
this.taxableYear,
socialCredit
);
this.labourCreditMonth = SalaryPaycheck.getAmountMonth(this.labourCredit);
this.generalCredit = SalaryPaycheck.getGeneralCredit(
year,
this.taxableYear,
older,
socialCredit
);
if (
this.taxWithoutCredit + this.labourCredit + this.generalCredit > 0 ||
(older &&
this.taxableYear < constants.lowWageThreshold[year] / socialCredit)
) {
this.generalCredit = -1 * (this.taxWithoutCredit + this.labourCredit);
}
this.generalCreditMonth = SalaryPaycheck.getAmountMonth(this.generalCredit);
this.taxCredit = roundNumber(this.labourCredit + this.generalCredit, 2);
this.taxCreditMonth = SalaryPaycheck.getAmountMonth(this.taxCredit);
this.incomeTax = roundNumber(this.taxWithoutCredit + this.taxCredit, 2);
this.incomeTaxMonth = SalaryPaycheck.getAmountMonth(this.incomeTax);
this.netYear = this.taxableYear + this.incomeTax + this.taxFreeYear;
this.netAllowance = allowance
? SalaryPaycheck.getHolidayAllowance(this.netYear)
: 0;
this.netMonth = SalaryPaycheck.getAmountMonth(this.netYear);
this.netWeek = SalaryPaycheck.getAmountWeek(this.netYear);
this.netDay = SalaryPaycheck.getAmountDay(this.netYear);
this.netHour = SalaryPaycheck.getAmountHour(this.netYear, hours);
}
static getHolidayAllowance(amountYear) {
return roundNumber(amountYear * (0.08 / 1.08), 2); // Vakantiegeld (8%)
}
static getTaxFree(taxFreeYear, grossYear) {
return roundNumber((taxFreeYear / grossYear) * 100, 2);
}
static getAmountMonth(amountYear) {
return roundNumber(amountYear / 12, 2);
}
static getAmountWeek(amountYear) {
return roundNumber(amountYear / constants.workingWeeks, 2);
}
static getAmountDay(amountYear) {
return roundNumber(amountYear / constants.workingDays, 2);
}
static getAmountHour(amountYear, hours) {
return roundNumber(amountYear / (constants.workingWeeks * hours), 2);
}
/**
* 30% Ruling (30%-regeling)
* https://www.belastingdienst.nl/wps/wcm/connect/bldcontentnl/belastingdienst/prive/internationaal/werken_wonen/tijdelijk_in_een_ander_land_werken/u_komt_in_nederland_werken/30_procent_regeling/voorwaarden_30_procent_regeling/u-hebt-een-specifieke-deskundigheid
*
* @param {string} year Year to retrieve information from
* @param {string} ruling Choice between scientific research workers, young professionals with Master's degree or others cases
* @returns {number} The 30% Ruling minimum income
*/
static getRulingIncome(year, ruling) {
return constants.rulingThreshold[year][ruling];
}
/**
* Payroll Tax Rates (Loonbelasting)
* https://www.belastingdienst.nl/bibliotheek/handboeken/html/boeken/HL/stappenplan-stap_7_loonbelasting_premie_volksverzekeringen.html
*
* @param {string} year Year to retrieve information from
* @param {number} salary Taxable wage that will be used for calculation
* @returns {number} The Payroll Tax Rates after calculating proper bracket amount
*/
static getPayrollTax(year, salary) {
return SalaryPaycheck.getRates(constants.payrollTax[year], salary, 'rate');
}
/**
* Social Security Contribution (Volksverzekeringen - AOW, Anw, Wlz)
* https://www.belastingdienst.nl/wps/wcm/connect/bldcontentnl/belastingdienst/prive/werk_en_inkomen/sociale_verzekeringen/premies_volks_en_werknemersverzekeringen/volksverzekeringen/volksverzekeringen
*
* @param {string} year Year to retrieve information from
* @param {number} salary Taxable wage that will be used for calculation
* @param {string} [older] Whether is after retirement age or not
* @returns {number} The Social Security Contribution after calculating proper bracket amount
*/
static getSocialTax(year, salary, older) {
return SalaryPaycheck.getRates(
constants.socialPercent[year],
salary,
older ? 'older' : 'social'
);
}
/**
* General Tax Credit (Algemene Heffingskorting)
* https://www.belastingdienst.nl/wps/wcm/connect/bldcontentnl/belastingdienst/prive/inkomstenbelasting/heffingskortingen_boxen_tarieven/heffingskortingen/algemene_heffingskorting/
*
* @param {string} year Year to retrieve information from
* @param {number} salary Taxable wage that will be used for calculation
* @param {boolean} older Whether is after retirement age or not
* @param {number} [multiplier] Scalar value to multiple against final result
* @returns {number} The General Tax Credit after calculating proper bracket amount
*/
static getGeneralCredit(year, salary, older, multiplier = 1) {
let generalCredit = SalaryPaycheck.getRates(
constants.generalCredit[year],
salary,
'rate',
multiplier
);
// Additional credit for worker that reached retirement age
if (older) {
generalCredit += SalaryPaycheck.getRates(
constants.elderCredit[year],
salary,
'rate'
);
}
return generalCredit;
}
/**
* Labour Tax Credit (Arbeidskorting)
* https://www.belastingdienst.nl/wps/wcm/connect/bldcontentnl/belastingdienst/prive/inkomstenbelasting/heffingskortingen_boxen_tarieven/heffingskortingen/arbeidskorting/
*
* @param {string} year Year to retrieve information from
* @param {number} salary Taxable wage that will be used for calculation
* @param {number} [multiplier] Scalar value to multiple against final result
* @returns {number} The Labour Tax Credit after calculating proper bracket amount
*/
static getLabourCredit(year, salary, multiplier = 1) {
if (salary < constants.lowWageThreshold[year] / multiplier) {
return 0;
}
return SalaryPaycheck.getRates(
constants.labourCredit[year],
salary,
'rate',
multiplier
);
}
/**
* Social Security Contribution (Volksverzekeringen) Component of Tax Credit
* https://www.belastingdienst.nl/wps/wcm/connect/bldcontentnl/belastingdienst/prive/werk_en_inkomen/sociale_verzekeringen/premies_volks_en_werknemersverzekeringen/volksverzekeringen/hoeveel_moet_u_betalen
*
* @param {string} year Year to retrieve information from
* @param {boolean} older Whether is after retirement age or not
* @param {boolean} socialSecurity Whether social security will be considered or not
* @returns {number} Social Security contribution percentage to apply to wage credit
*/
static getSocialCredit(year, older, socialSecurity) {
/*
* JSON properties for socialPercent object
* rate: Higher full rate including social contributions to be used to get proportion
* social: Percentage of social contributions (AOW + Anw + Wlz)
* older: Percentage for retirement age (Anw + Wlz, no contribution to AOW)
*/
let bracket = constants.socialPercent[year][0],
percentage = 1;
if (!socialSecurity) {
percentage = (bracket.rate - bracket.social) / bracket.rate; // Removing AOW + Anw + Wlz from total
} else if (older) {
percentage =
(bracket.rate + bracket.older - bracket.social) / bracket.rate; // Removing only AOW from total
}
return percentage;
}
/**
* Get right amount based on the rate brackets passed
* https://www.belastingdienst.nl/wps/wcm/connect/bldcontentnl/themaoverstijgend/brochures_en_publicaties/nieuwsbrief-loonheffingen-2020
*
* @param {object[]} brackets Rate brackets to extract information from
* @param {number} salary Taxable wage that will be used for calculation
* @param {string} kind Property name to be extracted from bracket
* @param {number} [multiplier] Scalar value to multiple against final result
* @returns {number} Accumulated tax/credit amount to be used to calculate the net income
*/
static getRates(brackets, salary, kind, multiplier = 1) {
let amount = 0,
tax,
delta,
isPercent;
brackets.some((bracket, _index) => {
delta = bracket.max ? bracket.max - bracket.min : Infinity; // Consider infinity when no upper bound
tax =
Math.round(
multiplier *
(kind && bracket[kind] ? bracket[kind] : bracket['rate']) *
100000
) / 100000;
isPercent = tax != 0 && tax > -1 && tax < 1; // Check if rate is percentage or fixed
if (salary <= delta) {
if (isPercent) {
amount += roundNumber(salary * tax, 2); // Round down at 2 decimal places
} else {
amount = tax;
}
amount = roundNumber(amount, 2);
return true; // Break loop when reach last bracket
} else {
if (isPercent) {
amount += roundNumber(delta * tax, 2);
} else {
amount = tax;
}
salary -= delta;
}
});
return amount;
}
}
/**
* Round a number to the specified decimal places
*
* @param {number} value Amount to be rounded
* @param {number} [places] Decimal places to rounded
*/
const roundNumber = (value, places = 2) => {
return Number(value.toFixed(places));
};
const NET_TO_GROSS_MAX_ITERATIONS = 30;
const NET_TO_GROSS_MATCH_EPSILON = 1e-6;
const NET_TO_GROSS_PLATEAU_SCAN_LIMIT = 1000; // cent steps each direction (±10.00)
const NET_TO_GROSS_COARSE_SCAN_STEPS = 4096; // fallback bracket search only
/**
* Reverse calculation: given a target net amount, find the gross that produces it.
*
* @param {object} target Target net figure to solve for
* @param {number} target.amount Target net amount
* @param {'netYear'|'netMonth'} target.field Which net field the amount refers to
* @param {boolean} target.holidayAllowanceIncluded Whether target.amount already includes the holiday allowance payout. Requires options.allowance or options.ruling.checked — SalaryPaycheck otherwise always computes netAllowance as 0, so this throws rather than silently ignoring the flag
* @param {object} options Same shape SalaryPaycheck accepts
* @param {'Year'|'Month'} options.period Must match target.field ('Year' for netYear, 'Month' for netMonth)
* @param {number} options.year Year to perform calculation
* @param {boolean} options.allowance Whether the solved gross should be treated as including holiday allowance
* @param {boolean} options.socialSecurity Whether social security is considered
* @param {boolean} options.older Whether is after retirement age or not
* @param {number} options.hours Working hours per week
* @param {object} options.ruling 30% ruling input, same shape SalaryPaycheck accepts
* @returns {SalaryPaycheck|{grossLow: number, grossHigh: number}} The solved SalaryPaycheck result, or the plateau bounds when rounding makes the gross non-unique
*/
const netToGross = (target, options) => {
const { amount, field, holidayAllowanceIncluded } = target || {};
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
throw new Error('netToGross: target.amount must be a finite number');
}
if (field !== 'netYear' && field !== 'netMonth') {
throw new Error("netToGross: target.field must be 'netYear' or 'netMonth'");
}
const { period, year, allowance, socialSecurity, older, hours, ruling } =
options || {};
const expectedPeriod = field === 'netYear' ? 'Year' : 'Month';
if (period !== expectedPeriod) {
throw new Error(
`netToGross: options.period must be '${expectedPeriod}' to match target.field '${field}'`
);
}
const grossField = expectedPeriod === 'Year' ? 'grossYear' : 'grossMonth';
// SalaryPaycheck only ever computes a nonzero netAllowance when allowance
// is true, or when the 30% ruling inflates it internally (gross-dependent,
// so it can't be ruled out up front). If neither can ever happen, no gross
// can satisfy a target that claims to include a holiday allowance payout
// that SalaryPaycheck will never compute — reject rather than silently
// solving for the plain net instead.
if (holidayAllowanceIncluded && !allowance && !(ruling && ruling.checked)) {
throw new Error(
'netToGross: target.holidayAllowanceIncluded requires options.allowance or options.ruling.checked — otherwise SalaryPaycheck always computes netAllowance as 0'
);
}
const netOf = (result) => {
const base = result[field];
if (!holidayAllowanceIncluded) {
return base;
}
// Dividing the annual (already 2-decimal) netAllowance by 12 produces
// fractional cents, so a clean 2-decimal monthly target could never
// satisfy the match tolerance. Round it to cents first, same as every
// other monthly figure SalaryPaycheck derives (see getAmountMonth).
const allowancePortion =
field === 'netYear'
? result.netAllowance
: roundNumber(result.netAllowance / 12, 2);
return base + allowancePortion;
};
const evaluate = (grossGuess) => {
const result = new SalaryPaycheck(
{ income: grossGuess, allowance, socialSecurity, older, hours },
period,
year,
ruling
);
return { grossGuess, result, net: netOf(result) };
};
const matches = (net) => Math.abs(net - amount) < NET_TO_GROSS_MATCH_EPSILON;
// Find the exact cent-granularity plateau of gross (income) values that all
// round to the same target net (SalaryPaycheck rounds every internal amount
// to 2 decimals, so a range of inputs can share one rounded net output).
//
// anchorGross is a raw (unrounded) value already confirmed to match the
// target net. Rounding it to cents does not always preserve that match
// (intermediate bracket rounding is sensitive to the exact fractional
// gross), so the rounded anchor is re-verified before it is trusted as the
// start of the cent-by-cent scan.
const findPlateau = (anchorGross) => {
const anchorResult = evaluate(anchorGross).result;
const anchorRounded = roundNumber(anchorGross, 2);
// Rounding can land just outside the matching cent value in either
// direction, so probe both neighbors before giving up on a clean
// cent-aligned seed.
const seed = [
anchorRounded,
roundNumber(anchorRounded - 0.01, 2),
roundNumber(anchorRounded + 0.01, 2),
].find((candidate) => matches(evaluate(candidate).net));
if (seed === undefined) {
// No clean cent-aligned gross near the solution reproduces the target
// net exactly; return the raw solution rather than rounding it away.
return anchorResult;
}
let rawLow = seed;
let rawHigh = seed;
for (let i = 0; i < NET_TO_GROSS_PLATEAU_SCAN_LIMIT; i++) {
const candidate = roundNumber(rawLow - 0.01, 2);
if (!matches(evaluate(candidate).net)) break;
rawLow = candidate;
}
for (let i = 0; i < NET_TO_GROSS_PLATEAU_SCAN_LIMIT; i++) {
const candidate = roundNumber(rawHigh + 0.01, 2);
if (!matches(evaluate(candidate).net)) break;
rawHigh = candidate;
}
if (rawLow === rawHigh) {
return evaluate(rawLow).result;
}
// The income fed into SalaryPaycheck is not always the reported gross
// (e.g. the 8%-holiday-allowance inflation), so translate the plateau
// edges through the actual reported field before reporting it.
const edgeA = evaluate(rawLow).result[grossField];
const edgeB = evaluate(rawHigh).result[grossField];
const grossLow = Math.min(edgeA, edgeB);
const grossHigh = Math.max(edgeA, edgeB);
if (grossLow === grossHigh) {
return evaluate(rawLow).result;
}
return { grossLow, grossHigh };
};
// Gross is not guaranteed to be >= net: when allowance=false and the 30%
// ruling applies, SalaryPaycheck inflates the supplied income by 8% before
// taxing it, so the true root can sit below the target net. 0 is the only
// sound universal lower bound.
let low = 0;
let high = amount * 3;
let highPoint = evaluate(high);
let expansions = 0;
while (highPoint.net < amount && expansions < NET_TO_GROSS_MAX_ITERATIONS) {
high *= 2;
highPoint = evaluate(high);
expansions++;
}
if (highPoint.net < amount) {
throw new Error(
`netToGross: target net ${amount} is not achievable within the search bounds; the nearest achievable net at gross ${high} is ${highPoint.net}`
);
}
// A fixed 30 iterations only reaches cent resolution while the bracket is
// reasonably small; for very large gross magnitudes (e.g. >1e12) it can
// leave a remaining bracket far wider than a cent. Scale the iteration
// count to the bracket width instead, capped generously since each extra
// iteration is cheap.
const bisectIterationsFor = (width) => {
if (!(width > 0)) {
return NET_TO_GROSS_MAX_ITERATIONS;
}
const needed = Math.ceil(Math.log2(width / 0.005)) + 4;
return Math.min(200, Math.max(NET_TO_GROSS_MAX_ITERATIONS, needed));
};
// Single-bracket bisection assumes net is monotonic in gross. Try the fast
// path first; it succeeds for the vast majority of inputs.
const bisect = (bracketLow, bracketHigh) => {
let bLow = bracketLow;
let bHigh = bracketHigh;
let bLowPoint = evaluate(bLow);
let bHighPoint = evaluate(bHigh);
const iterations = bisectIterationsFor(bHigh - bLow);
for (let i = 0; i < iterations; i++) {
const midGross = (bLow + bHigh) / 2;
if (midGross === bLow || midGross === bHigh) {
// Floating-point spacing between bLow and bHigh is exhausted; no
// further narrowing is possible.
break;
}
const midPoint = evaluate(midGross);
if (matches(midPoint.net)) {
return { hit: findPlateau(midPoint.grossGuess) };
}
if (midPoint.net < amount) {
bLow = midGross;
bLowPoint = midPoint;
} else {
bHigh = midGross;
bHighPoint = midPoint;
}
}
const nearest =
Math.abs(bLowPoint.net - amount) <= Math.abs(bHighPoint.net - amount)
? bLowPoint
: bHighPoint;
return { nearest, bracketLow: bLow, bracketHigh: bHigh };
};
const fastPath = bisect(low, high);
if (fastPath.hit) {
return fastPath.hit;
}
// The fast path didn't converge on an exact match. Net income is not
// guaranteed to be monotonic in gross (see issue #85: net can decrease as
// gross increases in narrow income bands), so a single bisection can
// converge on the wrong side of a local dip and miss an otherwise
// achievable target. Fall back to a coarse scan for every bracket where
// net crosses the target, and bisect within each candidate bracket.
let nearest = fastPath.nearest;
let nearestBracketWidth = fastPath.bracketHigh - fastPath.bracketLow;
const updateNearest = (point, bracketWidth = 0) => {
if (Math.abs(point.net - amount) < Math.abs(nearest.net - amount)) {
nearest = point;
nearestBracketWidth = bracketWidth;
}
};
const step = (high - low) / NET_TO_GROSS_COARSE_SCAN_STEPS;
let prevGross = low;
let prevPoint = evaluate(prevGross);
updateNearest(prevPoint);
for (let i = 1; i <= NET_TO_GROSS_COARSE_SCAN_STEPS; i++) {
const gross = low + step * i;
const point = evaluate(gross);
updateNearest(point, step);
const crosses = (prevPoint.net - amount) * (point.net - amount) < 0;
if (matches(point.net)) {
return findPlateau(point.grossGuess);
}
if (crosses) {
const bracketResult = bisect(prevGross, gross);
if (bracketResult.hit) {
return bracketResult.hit;
}
updateNearest(
bracketResult.nearest,
bracketResult.bracketHigh - bracketResult.bracketLow
);
}
prevGross = gross;
prevPoint = point;
}
// Last resort: some non-monotonic bands are only a single cent wide (a
// brief spike or dip that the coarse scan can straddle without landing
// inside it). A bounded cent-by-cent scan right around the closest miss
// found so far catches those without paying for cent resolution across
// the whole search range. Size the window from whatever bracket produced
// the closest miss — a fixed small window can be narrower than the
// remaining uncertainty for very large gross magnitudes, excluding the
// actual root.
const fineRadius = Math.min(50, Math.max(2, nearestBracketWidth));
const fineWindowLow = roundNumber(
Math.max(low, nearest.grossGuess - fineRadius),
2
);
const fineWindowHigh = roundNumber(
Math.min(high, nearest.grossGuess + fineRadius),
2
);
// Stepping by adding 0.01 in a loop condition can stall forever once gross
// is large enough that floating-point spacing exceeds a cent (e.g. ~1e15),
// since gross + 0.01 then rounds back to gross itself. Bound by an integer
// step count instead so the loop always terminates.
const fineSteps = Math.max(
0,
Math.round((fineWindowHigh - fineWindowLow) / 0.01)
);
for (let i = 0; i <= fineSteps; i++) {
const gross = roundNumber(fineWindowLow + i * 0.01, 2);
const point = evaluate(gross);
updateNearest(point);
if (matches(point.net)) {
return findPlateau(point.grossGuess);
}
}
throw new Error(
`netToGross: no gross value converges to the exact target net of ${amount}; nearest achievable net is ${nearest.net} (${grossField}: ${nearest.result[grossField]})`
);
};
export { SalaryPaycheck, constants, netToGross };