use super::funejson::Value; use super::dates_add_days::epoch_day_from_iso; use super::math_round_div::round_div; use super::money_amount::{assert_same_currency, money, money_from_value, money_to_value}; /// Perpetual weighted average cost. The stock's value is held exactly and each /// issue takes value x issued / on hand, rounded once, so the last issue takes /// exactly what is left and an empty item is worth nothing. /// /// # Panics /// Panics on a ledger out of date order, a zero quantity, a receipt without a /// unit cost or with a negative one, an issue with a unit cost, a cost in /// another currency, an issue larger than the stock on hand, or an unknown mode. pub fn weighted_average_valuation(movements: &[StockMovement], currency: &str, mode: &str) -> AverageCostValuation { let zero = money(0, currency); round_div(0, 1, mode); // an unknown mode fails even on a ledger with no issues let mut quantity: i64 = 0; let mut value: i64 = 0; let mut cost_of_sales: i64 = 0; let mut issue_costs = Vec::new(); let mut previous: Option<(i64, &str)> = None; for line in movements { let day = epoch_day_from_iso(&line.date); if let Some((previous_day, previous_date)) = previous { if day < previous_day { panic!("movements must be in date order: {} comes after {}", line.date, previous_date); } } previous = Some((day, &line.date)); if line.quantity == 0 { panic!("quantity must be a non-zero whole number, received 0 on {}", line.date); } if line.quantity > 0 { let unit_cost = match &line.unit_cost { Some(c) => c, None => panic!("a receipt needs a unitCost: {} on {}", line.quantity, line.date), }; assert_same_currency(&zero, unit_cost); if unit_cost.minor < 0 { panic!("unitCost must not be negative, received {} on {}", unit_cost.minor, line.date); } quantity += line.quantity; value += line.quantity * unit_cost.minor; continue; } if line.unit_cost.is_some() { panic!( "an issue takes its cost from stock, so its unitCost must be null: {} on {}", line.quantity, line.date ); } let issued = -line.quantity; if issued > quantity { panic!( "insufficient stock: an issue of {} on {} exceeds the {} on hand", issued, line.date, quantity ); } let cost = round_div(value * issued, quantity, mode); issue_costs.push(money(cost, currency)); cost_of_sales += cost; value -= cost; quantity -= issued; } AverageCostValuation { closing_quantity: quantity, closing_value: money(value, currency), cost_of_sales: money(cost_of_sales, currency), issue_costs, average_unit_cost: money(if quantity == 0 { 0 } else { round_div(value, quantity, mode) }, currency), } } pub fn stock_movement_from_value(v: &Value) -> StockMovement { let cost = v.get("unitCost"); StockMovement { date: v.get("date").as_str().to_string(), quantity: v.get("quantity").as_i64(), unit_cost: if cost.is_null() { None } else { Some(money_from_value(cost)) }, } } pub fn average_cost_valuation_to_value(result: &AverageCostValuation) -> Value { Value::obj(vec![ ("closingQuantity", Value::Int(result.closing_quantity)), ("closingValue", money_to_value(&result.closing_value)), ("costOfSales", money_to_value(&result.cost_of_sales)), ("issueCosts", Value::Arr(result.issue_costs.iter().map(money_to_value).collect())), ("averageUnitCost", money_to_value(&result.average_unit_cost)), ]) } pub fn fune_vector(args: &[Value]) -> Value { let movements: Vec = args[0] .as_arr() .iter() .map(|v| { if let Value::Float(f) = v.get("quantity") { panic!("quantity must be a non-zero whole number, received {} on {}", f, v.get("date").as_str()); } stock_movement_from_value(v) }) .collect(); average_cost_valuation_to_value(&weighted_average_valuation(&movements, args[1].as_str(), args[2].as_str())) }