use super::funejson::Value; use super::dates_add_days::add_days; use super::hospitality_tourist_tax_data::{VisitorLevy, VISITOR_LEVIES, VISITOR_LEVIES_HISTORY}; use super::money_amount::{assert_same_currency, money, money_from_value, money_to_value, Money}; use super::money_apply_rate::apply_rate; use super::money_sum::sum_money; 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| 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 { 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| 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::>(), 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]))) }