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, ¤cy);
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, ¤cy),
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], ¤cy), rule.basis_points, "half-up")
} else {
assert_same_currency(&money(0, ¤cy), &rule.amount);
let people = stay.adults + if rule.under18_exempt { 0 } else { stay.children };
money(rule.amount.minor * people * leviable, ¤cy)
};
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])))
}