Functional Weave
Code in Rust

hospitality.tourist-tax@1.0.1

impl/rust.rs

8,089 bytes · the Rust 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::add_days;  ← from dates.add-days ^1.0.0 · built alongside by fune
use super::hospitality_tourist_tax_data::{VisitorLevy, VISITOR_LEVIES, VISITOR_LEVIES_HISTORY};  ← this capability’s own data, compiled from data/visitor-levies.json into the same file by fune build
use super::money_amount::{assert_same_currency, money, money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune
use super::money_apply_rate::apply_rate;  ← from money.apply-rate ^1.0.0 · built alongside by fune
use super::money_sum::sum_money;  ← from money.sum ^1.0.0 · built alongside by fune

fn check_rule(rule: &LevyRule) {
    if rule.method != "percent" && rule.method != "per-person-night" {
        panic!("unknown levy method \"{}\"", rule.method);
    }
    if !(0..=10000).contains(&rule.basis_points) {
        panic!("basisPoints must be a whole number from 0 to 10000, received {}", rule.basis_points);
    }
    if rule.amount.minor < 0 {
        panic!("amount must not be negative, received {}", rule.amount.minor);
    }
    if let Some(n) = rule.max_nights {
        if n < 1 {
            panic!("maxNights must be at least 1 or null, received {}", n);
        }
    }
    if let Some(n) = rule.exempt_over_nights {
        if n < 1 {
            panic!("exemptOverNights must be at least 1 or null, received {}", n);
        }
    }
}

fn to_rule(row: &VisitorLevy) -> LevyRule {
    LevyRule {
        method: row.method.to_string(),
        basis_points: row.basis_points,
        amount: money(row.amount_minor, row.currency),
        max_nights: row.max_nights,
        exempt_over_nights: row.exempt_over_nights,
        under18_exempt: row.under18_exempt,
    }
}

/// 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.
///
/// # Panics
/// Panics on a malformed stay or rule, an unknown locality, a custom rule
/// where none belongs (or none where one is needed), or mixed currencies.
pub fn tourist_tax(locality: &str, custom_rule: Option<&LevyRule>, stay: &Stay) -> TouristTax {
    let charges = &stay.nightly_charges;
    if charges.is_empty() {
        panic!("a stay needs at least one night");
    }
    let currency = charges[0].currency.clone();
    sum_money(charges, &currency);
    for c in charges {
        if c.minor < 0 {
            panic!("nightly charges must not be negative, received {}", c.minor);
        }
    }
    if stay.accommodation != "standard" && stay.accommodation != "shared" {
        panic!("unknown accommodation type \"{}\"", stay.accommodation);
    }
    if stay.adults < 0 {
        panic!("adults must not be negative, received {}", stay.adults);
    }
    if stay.children < 0 {
        panic!("children must not be negative, received {}", stay.children);
    }
    add_days(&stay.arrival, 0);
    add_days(&stay.booked_on, 0);
    if let Some(paid) = &stay.first_paid_on {
        add_days(paid, 0);
    }

    let none = |exemption: &str, rule: Option<LevyRule>| TouristTax {
        locality: locality.to_string(),
        levy: money(0, &currency),
        leviable_nights: 0,
        exemption: Some(exemption.to_string()),
        rule,
    };

    let rule: LevyRule = if locality == "custom" {
        let rule = match custom_rule {
            Some(r) => r.clone(),
            None => panic!("locality custom needs a customRule"),
        };
        check_rule(&rule);
        rule
    } else {
        if custom_rule.is_some() {
            panic!("customRule is only used with locality custom");
        }
        let rows: Vec<&VisitorLevy> = VISITOR_LEVIES.iter().filter(|r| r.locality == locality).collect();
        if rows.is_empty() {
            panic!("no visitor levy data for locality \"{}\"", locality);
        }
        let arrival = stay.arrival.as_str();
        let row = rows.iter().find(|r| {
            (r.accommodation == "any" || r.accommodation == stay.accommodation)
                && r.valid_from <= arrival
                && r.valid_to.map_or(true, |to| arrival <= to)
        });
        let row = match row {
            Some(r) => *r,
            None => {
                if VISITOR_LEVIES_HISTORY != "full" {
                    let earliest = rows.iter().map(|r| r.valid_from).min().unwrap();
                    if arrival < earliest {
                        panic!(
                            "no visitor levy rule for {} on {}: this build was installed with history={}, so it only carries rules from {}",
                            locality, arrival, VISITOR_LEVIES_HISTORY, earliest
                        );
                    }
                }
                return none("not-in-force", None);
            }
        };
        let rule = to_rule(row);
        if let (Some(cutoff), Some(paid)) = (row.paid_bookings_exempt_before, &stay.first_paid_on) {
            if stay.booked_on.as_str() < cutoff && paid.as_str() < cutoff {
                return none("booked-and-paid-before-scheme", Some(rule));
            }
        }
        rule
    };

    let nights = charges.len() as i64;
    if let Some(limit) = rule.exempt_over_nights {
        if nights > limit {
            return none("long-stay", Some(rule));
        }
    }
    let leviable = match rule.max_nights {
        Some(max) => nights.min(max),
        None => nights,
    };
    let levy = if rule.method == "percent" {
        apply_rate(&sum_money(&charges[..leviable as usize], &currency), rule.basis_points, "half-up")
    } else {
        assert_same_currency(&money(0, &currency), &rule.amount);
        let people = stay.adults + if rule.under18_exempt { 0 } else { stay.children };
        money(rule.amount.minor * people * leviable, &currency)
    };
    TouristTax { locality: locality.to_string(), levy, leviable_nights: leviable, exemption: None, rule: Some(rule) }
}

fn opt_int(v: &Value) -> Option<i64> {
    if v.is_null() {
        None
    } else {
        Some(v.as_i64())
    }
}

pub fn levy_rule_from_value(v: &Value) -> LevyRule {
    LevyRule {
        method: v.get("method").as_str().to_string(),
        basis_points: v.get("basisPoints").as_i64(),
        amount: money_from_value(v.get("amount")),
        max_nights: opt_int(v.get("maxNights")),
        exempt_over_nights: opt_int(v.get("exemptOverNights")),
        under18_exempt: v.get("under18Exempt").as_bool(),
    }
}

pub fn levy_rule_to_value(r: &LevyRule) -> Value {
    let int_or_null = |n: Option<i64>| n.map_or(Value::Null, Value::Int);
    Value::obj(vec![
        ("method", Value::str(&r.method)),
        ("basisPoints", Value::Int(r.basis_points)),
        ("amount", money_to_value(&r.amount)),
        ("maxNights", int_or_null(r.max_nights)),
        ("exemptOverNights", int_or_null(r.exempt_over_nights)),
        ("under18Exempt", Value::Bool(r.under18_exempt)),
    ])
}

pub fn stay_from_value(v: &Value) -> Stay {
    Stay {
        arrival: v.get("arrival").as_str().to_string(),
        nightly_charges: v.get("nightlyCharges").as_arr().iter().map(money_from_value).collect::<Vec<Money>>(),
        accommodation: v.get("accommodation").as_str().to_string(),
        adults: v.get("adults").as_i64(),
        children: v.get("children").as_i64(),
        booked_on: v.get("bookedOn").as_str().to_string(),
        first_paid_on: if v.get("firstPaidOn").is_null() {
            None
        } else {
            Some(v.get("firstPaidOn").as_str().to_string())
        },
    }
}

pub fn tourist_tax_to_value(t: &TouristTax) -> Value {
    Value::obj(vec![
        ("locality", Value::str(&t.locality)),
        ("levy", money_to_value(&t.levy)),
        ("leviableNights", Value::Int(t.leviable_nights)),
        ("exemption", t.exemption.as_deref().map_or(Value::Null, Value::str)),
        ("rule", t.rule.as_ref().map_or(Value::Null, levy_rule_to_value)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let custom = if args[1].is_null() { None } else { Some(levy_rule_from_value(&args[1])) };
    tourist_tax_to_value(&tourist_tax(args[0].as_str(), custom.as_ref(), &stay_from_value(&args[2])))
}