Functional Weave
Code in TypeScript

hospitality.room-rate@1.0.0

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) };
}