use super::funejson::Value; use super::money_amount::{money, money_from_value, money_to_value, Money}; fn same_currency(currency: &str, amount: &Money) { if amount.currency != currency { panic!("currency mismatch: {} and {}", amount.currency, currency); } } /// Charge spending to funds the way charity fund accounting requires: a spend /// for a restricted purpose uses that purpose's restricted funds first (in the /// order given), and only what they cannot cover falls on the unrestricted /// funds. General spending never touches a restricted fund, and no fund may go /// below zero. /// /// # Panics /// Panics on mixed currencies, duplicate fund ids, a restricted fund without a /// purpose or an unrestricted one with one, negative balances or spends, and a /// spend the funds cannot cover. pub fn allocate_fund_spend(funds: &[Fund], spends: &[FundSpend]) -> FundAllocation { let currency: String = if let Some(f) = funds.first() { f.balance.currency.clone() } else if let Some(s) = spends.first() { s.amount.currency.clone() } else { "GBP".to_string() }; let mut seen: Vec<&str> = Vec::new(); for fund in funds { if seen.contains(&fund.id.as_str()) { panic!("duplicate fund id \"{}\"", fund.id); } seen.push(&fund.id); same_currency(¤cy, &fund.balance); if fund.restricted && fund.purpose.is_none() { panic!("restricted fund \"{}\" needs a purpose", fund.id); } if !fund.restricted && fund.purpose.is_some() { panic!("unrestricted fund \"{}\" must not have a purpose", fund.id); } if fund.balance.minor < 0 { panic!("fund \"{}\" balance must not be negative, received {}", fund.id, fund.balance.minor); } } for spend in spends { same_currency(¤cy, &spend.amount); if spend.amount.minor < 0 { panic!("spend \"{}\" amount must not be negative, received {}", spend.id, spend.amount.minor); } } let mut balances: Vec = funds.iter().map(|f| f.balance.minor).collect(); let mut draws: Vec = Vec::new(); for spend in spends { let mut remaining = spend.amount.minor; let mut order: Vec = Vec::new(); if let Some(purpose) = &spend.purpose { order.extend((0..funds.len()).filter(|&i| funds[i].restricted && funds[i].purpose.as_ref() == Some(purpose))); } order.extend((0..funds.len()).filter(|&i| !funds[i].restricted)); for i in order { let amount = remaining.min(balances[i]); if amount <= 0 { continue; } balances[i] -= amount; remaining -= amount; draws.push(FundDraw { spend_id: spend.id.clone(), fund_id: funds[i].id.clone(), amount: money(amount, ¤cy), }); } if remaining > 0 { panic!("insufficient funds for spend \"{}\": short by {}", spend.id, remaining); } } FundAllocation { draws, funds: funds .iter() .enumerate() .map(|(i, f)| Fund { id: f.id.clone(), restricted: f.restricted, purpose: f.purpose.clone(), balance: money(balances[i], ¤cy), }) .collect(), } } fn optional_str(v: &Value) -> Option { if v.is_null() { None } else { Some(v.as_str().to_string()) } } pub fn fund_from_value(v: &Value) -> Fund { Fund { id: v.get("id").as_str().to_string(), restricted: v.get("restricted").as_bool(), purpose: optional_str(v.get("purpose")), balance: money_from_value(v.get("balance")), } } pub fn fund_spend_from_value(v: &Value) -> FundSpend { FundSpend { id: v.get("id").as_str().to_string(), purpose: optional_str(v.get("purpose")), amount: money_from_value(v.get("amount")), } } pub fn fund_allocation_to_value(a: &FundAllocation) -> Value { Value::obj(vec![ ( "draws", Value::Arr( a.draws .iter() .map(|d| { Value::obj(vec![ ("spendId", Value::str(&d.spend_id)), ("fundId", Value::str(&d.fund_id)), ("amount", money_to_value(&d.amount)), ]) }) .collect(), ), ), ( "funds", Value::Arr( a.funds .iter() .map(|f| { Value::obj(vec![ ("id", Value::str(&f.id)), ("restricted", Value::Bool(f.restricted)), ("purpose", match &f.purpose { Some(p) => Value::str(p), None => Value::Null }), ("balance", money_to_value(&f.balance)), ]) }) .collect(), ), ), ]) } pub fn fune_vector(args: &[Value]) -> Value { let funds: Vec = args[0].as_arr().iter().map(fund_from_value).collect(); let spends: Vec = args[1].as_arr().iter().map(fund_spend_from_value).collect(); fund_allocation_to_value(&allocate_fund_spend(&funds, &spends)) }