impl/typescript.ts
2,069 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 { civilFromDays, daysInMonth, epochDayFromIso } from "./dates_add_days.ts"; ← from dates.add-days ^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 { type RentProrationMethod } from "./property_rent_proration_types.ts";
const MAX_MINOR = 1_000_000_000;
// lcm(28, 29, 30, 31): every month's daily rate is a whole number of these parts.
const MONTH_PARTS = 377_580;
/**
* Rent for the days fromDate to toDate inclusive, from a monthly rent, as one
* exact fraction rounded half-up once. The daily rate is never rounded on its
* own, which is the usual source of a pound's error on a part-month invoice.
*/
export function prorateRent(monthlyRent: Money, fromDate: string, toDate: string, method: RentProrationMethod): Money {
if (!Number.isInteger(monthlyRent.minor) || Math.abs(monthlyRent.minor) > MAX_MINOR) {
throw new RangeError(`monthlyRent must be at most ${MAX_MINOR} minor units either way, received ${monthlyRent.minor}`);
}
const start = epochDayFromIso(fromDate);
const end = epochDayFromIso(toDate);
if (end < start) {
throw new RangeError(`toDate ${toDate} is before fromDate ${fromDate}`);
}
const days = end - start + 1;
if (days > 366) {
throw new RangeError(`the period must be at most 366 days, received ${days}`);
}
if (method === "annual-daily") {
return money(roundDiv(monthlyRent.minor * 12 * days, 365, "half-up"), monthlyRent.currency);
}
if (method !== "calendar-month") {
throw new RangeError(`unknown method "${method}": use annual-daily or calendar-month`);
}
// Each day is worth MONTH_PARTS / (days in its month) parts of a month.
let parts = 0;
let day = start;
while (day <= end) {
const date = civilFromDays(day);
const length = daysInMonth(date.year, date.month);
const segmentEnd = Math.min(end, day + (length - date.day));
parts += (segmentEnd - day + 1) * (MONTH_PARTS / length);
day = segmentEnd + 1;
}
return money(roundDiv(monthlyRent.minor * parts, MONTH_PARTS, "half-up"), monthlyRent.currency);
}