use super::funejson::Value; use super::construction_vat_reverse_charge_data::{ ReverseChargeRule, REVERSE_CHARGE_RULES, REVERSE_CHARGE_RULES_HISTORY, REVERSE_CHARGE_RULES_HORIZON, }; 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()) } fn not_applied(reason: &str) -> ReverseChargeDecision { ReverseChargeDecision { applies: false, reason: reason.to_string(), invoice_wording: None, } } /// Whether the UK VAT domestic reverse charge for building and construction /// services (VATA 1994 s.55A) applies to one supply. /// /// The conditions are checked in a fixed order and the first that fails is the /// reason, so the answer is the same in every language and explains itself. /// /// # Panics /// Panics on a malformed date, a share outside 0..=10000 or an unknown category. pub fn domestic_reverse_charge(supply: &ReverseChargeSupply) -> ReverseChargeDecision { if !is_iso_date(&supply.supply_date) { panic!( "supplyDate must be an ISO date (YYYY-MM-DD), received \"{}\"", supply.supply_date ); } let share = supply.reverse_charge_share_basis_points; if !(0..=10000).contains(&share) { panic!( "reverseChargeShareBasisPoints must be between 0 and 10000, received {}", share ); } let category = supply.vat_category.as_str(); if !["standard", "reduced", "zero", "exempt"].contains(&category) { panic!( "unknown vatCategory \"{}\": expected standard, reduced, zero or exempt", category ); } let date = supply.supply_date.as_str(); let mut rule: Option<&ReverseChargeRule> = None; for row in REVERSE_CHARGE_RULES.iter() { if row.jurisdiction != "GB" || date < row.valid_from { continue; } if let Some(valid_to) = row.valid_to { if date > valid_to { continue; } } if rule.map_or(true, |r| row.valid_from > r.valid_from) { rule = Some(row); } } let rule = match rule { Some(rule) => rule, None => { // A pruned build must not answer "before the reverse charge started" // for a date it simply no longer carries rules for. if let Some(horizon) = REVERSE_CHARGE_RULES_HORIZON { if date < horizon { panic!( "no reverse charge rule for {}: this build was installed with history={}, so it only carries rules from {}. Reinstall with history=full.", date, REVERSE_CHARGE_RULES_HISTORY, horizon ); } } return not_applied("before-start"); } }; if !supply.supplier_vat_registered { return not_applied("supplier-not-vat-registered"); } if !supply.customer_vat_registered { return not_applied("customer-not-vat-registered"); } if !supply.reported_under_cis { return not_applied("not-reported-under-cis"); } if category != "standard" && category != "reduced" { return not_applied("not-standard-or-reduced"); } if supply.employment_business { return not_applied("employment-business"); } if supply.end_user_notified { return not_applied("end-user-notified"); } if share <= rule.disregard_basis_points { return not_applied("five-percent-disregard"); } ReverseChargeDecision { applies: true, reason: "reverse-charge".to_string(), invoice_wording: Some(rule.invoice_wording.to_string()), } } pub fn reverse_charge_supply_from_value(v: &Value) -> ReverseChargeSupply { let share = v.get("reverseChargeShareBasisPoints"); if let Value::Float(f) = share { panic!("reverseChargeShareBasisPoints must be an integer, received {}", f); } let date = v.get("supplyDate"); if !matches!(date, Value::Str(_)) { panic!("supplyDate must be an ISO date (YYYY-MM-DD), received \"{:?}\"", date); } ReverseChargeSupply { supply_date: date.as_str().to_string(), supplier_vat_registered: v.get("supplierVatRegistered").as_bool(), customer_vat_registered: v.get("customerVatRegistered").as_bool(), reported_under_cis: v.get("reportedUnderCis").as_bool(), vat_category: v.get("vatCategory").as_str().to_string(), employment_business: v.get("employmentBusiness").as_bool(), end_user_notified: v.get("endUserNotified").as_bool(), reverse_charge_share_basis_points: share.as_i64(), } } pub fn reverse_charge_decision_to_value(decision: &ReverseChargeDecision) -> Value { Value::obj(vec![ ("applies", Value::Bool(decision.applies)), ("reason", Value::str(&decision.reason)), ( "invoiceWording", match &decision.invoice_wording { Some(text) => Value::str(text), None => Value::Null, }, ), ]) } pub fn fune_vector(args: &[Value]) -> Value { reverse_charge_decision_to_value(&domestic_reverse_charge(&reverse_charge_supply_from_value(&args[0]))) }