use super::funejson::Value; use super::money_amount::{money, money_from_value, money_to_value, Money}; use super::money_apply_rate::apply_rate; use super::retail_oss_vat_data::{OSS_RULES, OSS_RULES_HISTORY}; fn is_iso_date(value: &str) -> bool { let bytes = value.as_bytes(); bytes.len() == 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes .iter() .enumerate() .all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit()) } // The EU writes Greece as EL in VAT matters; ISO 3166 says GR. Accept both. fn country(code: &str, name: &str) -> String { if code.len() != 2 || !code.bytes().all(|b| b.is_ascii_uppercase()) { panic!("{} must be an ISO 3166-1 alpha-2 code, received \"{}\"", name, code); } if code == "EL" { "GR".to_string() } else { code.to_string() } } fn is_member(code: &str) -> bool { OSS_RULES.iter().any(|r| r.rule == "standard-rate" && r.country == code) } fn lookup(rule: &str, place: &str, on_date: &str) -> i64 { let mut best: Option<(&str, i64)> = None; for r in OSS_RULES { if r.rule != rule || r.country != place { continue; } if on_date < r.valid_from { continue; } if let Some(to) = r.valid_to { if on_date > to { continue; } } if best.map_or(true, |(from, _)| r.valid_from > from) { best = Some((r.valid_from, r.value)); } } if let Some((_, value)) = best { return value; } // A history=current build carries only the rules still in force; answering // an older date with today's rate is the failure the dates exist to prevent. if OSS_RULES_HISTORY != "full" { let mut earliest: Option<&str> = None; for r in OSS_RULES { if r.rule == rule && r.country == place && earliest.map_or(true, |e| r.valid_from < e) { earliest = Some(r.valid_from); } } if let Some(earliest) = earliest { if on_date < earliest { panic!( "no OSS rule \"{}\" for {} on {}: this build was installed with history={}, so it only carries rules from {}. Reinstall with history=full for older supplies.", rule, place, on_date, OSS_RULES_HISTORY, earliest ); } } } panic!("no OSS rule \"{}\" for {} on {}", rule, place, on_date) } /// VAT on a business-to-consumer sale to another EU member state: the /// seller's own rate while its cross-border sales stay within EUR 10,000 (and /// it has not opted in), the customer's state's rate through the One Stop /// Shop once they exceed it, and always the customer's for sellers outside /// the EU. /// /// # Panics /// Panics on a malformed date or country code, a customer outside the EU, /// threshold totals not in EUR, or a date no rule covers. pub fn oss_vat( net: &Money, seller_country: &str, customer_country: &str, eu_sales_previous_year: &Money, eu_sales_this_year: &Money, opted_in: bool, on_date: &str, ) -> OssVat { if !is_iso_date(on_date) { panic!("onDate must be an ISO date (YYYY-MM-DD), received \"{}\"", on_date); } let seller = country(seller_country, "sellerCountry"); let customer = country(customer_country, "customerCountry"); if !is_member(&customer) { panic!("customer country \"{}\" is not an EU member state", customer_country); } for (name, amount) in [ ("euSalesPreviousYear", eu_sales_previous_year), ("euSalesThisYear", eu_sales_this_year), ] { if amount.currency != "EUR" { panic!("{} must be in EUR, received {}", name, amount.currency); } if amount.minor < 0 { panic!("{} must not be negative, received {}", name, amount.minor); } } let threshold = lookup("distance-selling-threshold", "EU", on_date); let threshold_exceeded = eu_sales_previous_year.minor > threshold || eu_sales_this_year.minor > threshold; let (scheme, tax_country) = if seller == customer { ("domestic", customer) } else if is_member(&seller) && !threshold_exceeded && !opted_in { ("origin", seller) } else { ("oss", customer) }; let basis_points = lookup("standard-rate", &tax_country, on_date); let vat = apply_rate(net, basis_points, "half-up"); OssVat { tax_country, scheme: scheme.to_string(), basis_points, threshold_exceeded, net: net.clone(), gross: money(net.minor + vat.minor, &net.currency), vat, } } pub fn oss_vat_to_value(o: &OssVat) -> Value { Value::obj(vec![ ("taxCountry", Value::str(&o.tax_country)), ("scheme", Value::str(&o.scheme)), ("basisPoints", Value::Int(o.basis_points)), ("thresholdExceeded", Value::Bool(o.threshold_exceeded)), ("net", money_to_value(&o.net)), ("vat", money_to_value(&o.vat)), ("gross", money_to_value(&o.gross)), ]) } pub fn fune_vector(args: &[Value]) -> Value { oss_vat_to_value(&oss_vat( &money_from_value(&args[0]), args[1].as_str(), args[2].as_str(), &money_from_value(&args[3]), &money_from_value(&args[4]), args[5].as_bool(), args[6].as_str(), )) }