-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathindex.js
More file actions
621 lines (576 loc) · 24.7 KB
/
Copy pathindex.js
File metadata and controls
621 lines (576 loc) · 24.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
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 };