payroll.minimum-wage-check@1.0.0
impl/typescript.ts
3,031 bytes · the TypeScript implementation · view raw
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { addMonths } from "./dates_add_months.ts"; ← from dates.add-months ^1.0.0 · built alongside by fune
import { age } from "./dates_age.ts"; ← from dates.age ^1.0.0 · built alongside by fune
import { roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { type Money, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { NMW_RATES, NMW_RATES_HISTORY, type NmwRate } from "./payroll_minimum_wage_check_data.ts"; ← this capability’s own data, compiled from data/rates.json into the same file by fune build
import { type MinimumWageCheck, type WageBand } from "./payroll_minimum_wage_check_types.ts";
function ratesInForce(periodStart: string): NmwRate[] {
const rows = NMW_RATES.filter((r) => periodStart >= r.validFrom && (r.validTo === null || periodStart <= r.validTo));
if (rows.length > 0) return rows;
if (NMW_RATES_HISTORY !== "full") {
let earliest: string | null = null;
for (const r of NMW_RATES) if (earliest === null || r.validFrom < earliest) earliest = r.validFrom;
if (earliest !== null && periodStart < earliest) {
throw new RangeError(
`no minimum wage rate for ${periodStart}: this build was installed with history=${NMW_RATES_HISTORY}, ` +
`so it only carries rates from ${earliest}. Reinstall with history=full to check older pay.`,
);
}
}
throw new RangeError(`no minimum wage rate for ${periodStart}`);
}
/**
* Minimum wage check for one pay reference period. The rate and the age are
* both taken on the first day of the period, never on the pay date.
*/
export function minimumWageCheck(
pay: Money,
minutesWorked: number,
birthDate: string,
periodStart: string,
apprenticeshipStart: string | null,
): MinimumWageCheck {
if (pay.currency !== "GBP") throw new RangeError(`the minimum wage is in GBP, received ${pay.currency}`);
if (!Number.isInteger(minutesWorked) || minutesWorked < 0) {
throw new RangeError(`minutesWorked must be a whole number of minutes, not negative, received ${minutesWorked}`);
}
const years = age(birthDate, periodStart);
if (years < 16) throw new RangeError(`a worker aged ${years} is below school leaving age: no minimum wage applies`);
if (apprenticeshipStart !== null && apprenticeshipStart > periodStart) {
throw new RangeError(`apprenticeshipStart ${apprenticeshipStart} is after periodStart ${periodStart}`);
}
const rows = ratesInForce(periodStart);
const apprenticeRate =
apprenticeshipStart !== null && (years < 19 || periodStart < addMonths(apprenticeshipStart, 12));
let chosen: NmwRate | null = null;
for (const row of rows) {
if (apprenticeRate) {
if (row.band === "apprentice") chosen = row;
} else if (row.band !== "apprentice" && row.minAge <= years && (chosen === null || row.minAge > chosen.minAge)) {
chosen = row;
}
}
if (chosen === null) throw new RangeError(`no minimum wage rate for ${periodStart}`);
const required = roundDiv(chosen.rateMinor * minutesWorked, 60, "up");
const shortfall = Math.max(0, required - pay.minor);
return {
band: chosen.band as WageBand,
hourlyRate: money(chosen.rateMinor, "GBP"),
required: money(required, "GBP"),
shortfall: money(shortfall, "GBP"),
compliant: shortfall === 0,
};
}