Functional Weave
Code in Rust

hospitality.tourist-tax@1.0.1

impl/typescript.ts

5,364 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 { type Money, assertSameCurrency, money } from "./money_amount.ts";  ← from money.amount ^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 { VISITOR_LEVIES, VISITOR_LEVIES_HISTORY, type VisitorLevy } from "./hospitality_tourist_tax_data.ts";  ← this capability’s own data, compiled from data/visitor-levies.json into the same file by fune build
import { type LevyRule, type Stay, type TouristTax } from "./hospitality_tourist_tax_types.ts";

function checkRule(rule: LevyRule): void {
  if (rule.method !== "percent" && rule.method !== "per-person-night") {
    throw new RangeError(`unknown levy method "${rule.method}"`);
  }
  if (!Number.isInteger(rule.basisPoints) || rule.basisPoints < 0 || rule.basisPoints > 10000) {
    throw new RangeError(`basisPoints must be a whole number from 0 to 10000, received ${rule.basisPoints}`);
  }
  if (rule.amount.minor < 0) throw new RangeError(`amount must not be negative, received ${rule.amount.minor}`);
  if (rule.maxNights !== null && (!Number.isInteger(rule.maxNights) || rule.maxNights < 1)) {
    throw new RangeError(`maxNights must be at least 1 or null, received ${rule.maxNights}`);
  }
  if (rule.exemptOverNights !== null && (!Number.isInteger(rule.exemptOverNights) || rule.exemptOverNights < 1)) {
    throw new RangeError(`exemptOverNights must be at least 1 or null, received ${rule.exemptOverNights}`);
  }
}

function toRule(row: VisitorLevy): LevyRule {
  return {
    method: row.method as LevyRule["method"],
    basisPoints: row.basisPoints,
    amount: money(row.amountMinor, row.currency),
    maxNights: row.maxNights,
    exemptOverNights: row.exemptOverNights,
    under18Exempt: row.under18Exempt,
  };
}

/**
 * The visitor levy on one stay.
 *
 * The rule is the one in force on the arrival date: Edinburgh does not levy a
 * stay that began before its scheme started, even for the nights after. A
 * percentage levy is taken of the accommodation charge of the levied nights
 * and rounded half-up once; a flat levy is the amount times the liable
 * people times the levied nights.
 */
export function touristTax(locality: string, customRule: LevyRule | null, stay: Stay): TouristTax {
  const charges = stay.nightlyCharges;
  if (charges.length === 0) throw new RangeError("a stay needs at least one night");
  const currency = charges[0].currency;
  sumMoney(charges, currency);
  for (const c of charges) {
    if (c.minor < 0) throw new RangeError(`nightly charges must not be negative, received ${c.minor}`);
  }
  if (stay.accommodation !== "standard" && stay.accommodation !== "shared") {
    throw new RangeError(`unknown accommodation type "${stay.accommodation}"`);
  }
  if (!Number.isInteger(stay.adults) || stay.adults < 0) throw new RangeError(`adults must not be negative, received ${stay.adults}`);
  if (!Number.isInteger(stay.children) || stay.children < 0) {
    throw new RangeError(`children must not be negative, received ${stay.children}`);
  }
  addDays(stay.arrival, 0);
  addDays(stay.bookedOn, 0);
  if (stay.firstPaidOn !== null) addDays(stay.firstPaidOn, 0);

  const none = (exemption: string, rule: LevyRule | null): TouristTax => ({
    locality,
    levy: money(0, currency),
    leviableNights: 0,
    exemption,
    rule,
  });

  let rule: LevyRule;
  if (locality === "custom") {
    if (customRule === null) throw new RangeError("locality custom needs a customRule");
    checkRule(customRule);
    rule = customRule;
  } else {
    if (customRule !== null) throw new RangeError("customRule is only used with locality custom");
    const rows = VISITOR_LEVIES.filter((r) => r.locality === locality);
    if (rows.length === 0) throw new RangeError(`no visitor levy data for locality "${locality}"`);
    const row = rows.find(
      (r) =>
        (r.accommodation === "any" || r.accommodation === stay.accommodation) &&
        r.validFrom <= stay.arrival &&
        (r.validTo === null || stay.arrival <= r.validTo),
    );
    if (row === undefined) {
      if (VISITOR_LEVIES_HISTORY !== "full") {
        const earliest = rows.reduce((e, r) => (r.validFrom < e ? r.validFrom : e), rows[0].validFrom);
        if (stay.arrival < earliest) {
          throw new RangeError(
            `no visitor levy rule for ${locality} on ${stay.arrival}: this build was installed with history=${VISITOR_LEVIES_HISTORY}, so it only carries rules from ${earliest}`,
          );
        }
      }
      return none("not-in-force", null);
    }
    rule = toRule(row);
    const cutoff = row.paidBookingsExemptBefore;
    if (cutoff !== null && stay.bookedOn < cutoff && stay.firstPaidOn !== null && stay.firstPaidOn < cutoff) {
      return none("booked-and-paid-before-scheme", rule);
    }
  }

  const nights = charges.length;
  if (rule.exemptOverNights !== null && nights > rule.exemptOverNights) return none("long-stay", rule);
  const leviableNights = rule.maxNights === null ? nights : Math.min(nights, rule.maxNights);

  let levy: Money;
  if (rule.method === "percent") {
    levy = applyRate(sumMoney(charges.slice(0, leviableNights), currency), rule.basisPoints, "half-up");
  } else {
    assertSameCurrency(money(0, currency), rule.amount);
    const people = stay.adults + (rule.under18Exempt ? 0 : stay.children);
    levy = money(rule.amount.minor * people * leviableNights, currency);
  }
  return { locality, levy, leviableNights, exemption: null, rule };
}