impl/typescript.ts
3,382 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 { addDays } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { dayOfWeek } from "./dates_day_of_week.ts"; ← from dates.day-of-week ^1.0.0 · built alongside by fune
import { subtractMoney } from "./money_add.ts"; ← from money.add ^1.0.0 · built alongside by fune
import { applyRate } from "./money_apply_rate.ts"; ← from money.apply-rate ^1.0.0 · built alongside by fune
import { sumMoney } from "./money_sum.ts"; ← from money.sum ^1.0.0 · built alongside by fune
import { type NightlyRate, type RoomNight, type RoomRateQuote, type StayDiscount } from "./hospitality_room_rate_types.ts";
/**
* Price a stay night by night from a rate table, then take off the
* length-of-stay discount the stay qualifies for.
*
* A night is named by the date it starts, so a Friday night is the night of
* a Friday. Exactly one row must match each night: two matching rows are
* an error rather than a guess about which one the hotel meant.
*/
export function roomRate(
rates: readonly NightlyRate[],
stayDiscounts: readonly StayDiscount[],
arrival: string,
nights: number,
): RoomRateQuote {
if (!Number.isInteger(nights) || nights < 1) throw new RangeError(`nights must be at least 1, received ${nights}`);
if (rates.length === 0) throw new RangeError("the rate table is empty");
for (const row of rates) {
// addDays parses both ends strictly, so a malformed date fails here.
addDays(row.firstNight, 0);
addDays(row.lastNight, 0);
if (row.lastNight < row.firstNight) throw new RangeError(`rate "${row.season}" ends before it starts`);
for (const d of row.daysOfWeek) {
if (!Number.isInteger(d) || d < 1 || d > 7) {
throw new RangeError(`daysOfWeek must be 1 (Monday) to 7 (Sunday), received ${d} in rate "${row.season}"`);
}
}
}
const seen = new Set<number>();
for (const discount of stayDiscounts) {
if (!Number.isInteger(discount.minNights) || discount.minNights < 1) {
throw new RangeError(`minNights must be at least 1, received ${discount.minNights}`);
}
if (seen.has(discount.minNights)) throw new RangeError(`two stay discounts start at ${discount.minNights} nights`);
seen.add(discount.minNights);
if (!Number.isInteger(discount.basisPoints) || discount.basisPoints < 0 || discount.basisPoints > 10000) {
throw new RangeError(`basisPoints must be a whole number from 0 to 10000, received ${discount.basisPoints}`);
}
}
const stay: RoomNight[] = [];
for (let i = 0; i < nights; i++) {
const date = addDays(arrival, i);
const weekday = dayOfWeek(date);
const matches = rates.filter(
(row) => row.firstNight <= date && date <= row.lastNight && (row.daysOfWeek.length === 0 || row.daysOfWeek.includes(weekday)),
);
if (matches.length === 0) throw new RangeError(`no rate for the night of ${date}`);
if (matches.length > 1) {
throw new RangeError(`more than one rate for the night of ${date}: "${matches[0].season}" and "${matches[1].season}"`);
}
stay.push({ date, season: matches[0].season, rate: matches[0].rate });
}
const subtotal = sumMoney(
stay.map((night) => night.rate),
stay[0].rate.currency,
);
let discountBasisPoints = 0;
let best = 0;
for (const discount of stayDiscounts) {
if (discount.minNights <= nights && discount.minNights > best) {
best = discount.minNights;
discountBasisPoints = discount.basisPoints;
}
}
const discount = applyRate(subtotal, discountBasisPoints, "half-up");
return { nights: stay, subtotal, discountBasisPoints, discount, total: subtractMoney(subtotal, discount) };
}