property.council-tax-band@1.0.0
impl/typescript.ts
2,467 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 { 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 { BAND_RATIOS, BAND_RATIOS_HISTORY, type BandRatio } from "./property_council_tax_band_data.ts"; ← this capability’s own data, compiled from data/band-ratios.json into the same file by fune build
import { type CouncilTaxBandCharge } from "./property_council_tax_band_types.ts";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const NATIONS = ["england", "wales", "scotland"];
// Keeps Band D × the largest proportion exact as a JavaScript number.
const MAX_MINOR = 1_000_000_000_000;
/**
* The council tax for a band: Band D × the band's statutory proportion ÷ Band
* D's, rounded half-up to the penny once. The proportions are dated rows, so
* a Scottish band E before April 2017 uses 440/360 and after it 473/360.
*/
export function councilTaxBand(bandDCharge: Money, band: string, nation: string, onDate: string): CouncilTaxBandCharge {
if (!ISO_DATE.test(onDate)) {
throw new RangeError(`onDate must be an ISO date (YYYY-MM-DD), received "${onDate}"`);
}
if (!NATIONS.includes(nation)) {
throw new RangeError(`unknown nation "${nation}": use england, wales or scotland`);
}
if (bandDCharge.currency !== "GBP") {
throw new RangeError(`bandDCharge must be in GBP, received ${bandDCharge.currency}`);
}
if (!Number.isInteger(bandDCharge.minor) || bandDCharge.minor < 0 || bandDCharge.minor > MAX_MINOR) {
throw new RangeError(`bandDCharge must be between 0 and ${MAX_MINOR} minor units, received ${bandDCharge.minor}`);
}
let found: BandRatio | null = null;
let earliest: string | null = null;
for (const row of BAND_RATIOS) {
if (row.nation !== nation) continue;
if (earliest === null || row.validFrom < earliest) earliest = row.validFrom;
if (row.band !== band || onDate < row.validFrom) continue;
if (row.validTo !== null && onDate > row.validTo) continue;
found = row;
}
if (found === null) {
if (BAND_RATIOS_HISTORY !== "full" && earliest !== null && onDate < earliest) {
throw new RangeError(
`no council tax band ${band} for ${nation} on ${onDate}: this build was installed with history=${BAND_RATIOS_HISTORY}, so it only carries rules from ${earliest}`,
);
}
throw new RangeError(`no council tax band ${band} for ${nation} on ${onDate}`);
}
return {
band,
ratioNumerator: found.numerator,
ratioDenominator: found.denominator,
charge: money(roundDiv(bandDCharge.minor * found.numerator, found.denominator, "half-up"), "GBP"),
};
}