#![allow(dead_code)]
#![allow(clippy::cast_precision_loss)]
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Currency {
Usd,
Eur,
Gbp,
Jpy,
}
impl Currency {
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::Usd => "USD",
Self::Eur => "EUR",
Self::Gbp => "GBP",
Self::Jpy => "JPY",
}
}
}
#[derive(Debug, Clone)]
pub struct Money {
pub amount_cents: i64,
pub currency: Currency,
}
impl Money {
#[must_use]
pub fn from_decimal(amount: f64, currency: Currency) -> Self {
Self {
amount_cents: (amount * 100.0).round() as i64,
currency,
}
}
#[must_use]
pub fn as_decimal(&self) -> f64 {
self.amount_cents as f64 / 100.0
}
#[must_use]
pub fn add(&self, other: &Self) -> Option<Self> {
if self.currency != other.currency {
return None;
}
Some(Self {
amount_cents: self.amount_cents + other.amount_cents,
currency: self.currency.clone(),
})
}
#[must_use]
pub fn exceeds(&self, budget: &Self) -> bool {
self.currency == budget.currency && self.amount_cents > budget.amount_cents
}
}
#[derive(Debug, Clone)]
pub struct StepCost {
pub step_id: String,
pub step_name: String,
pub estimated: Money,
pub actual: Option<Money>,
pub cost_center: Option<String>,
}
impl StepCost {
#[must_use]
pub fn new(step_id: &str, step_name: &str, estimated: Money) -> Self {
Self {
step_id: step_id.to_string(),
step_name: step_name.to_string(),
estimated,
actual: None,
cost_center: None,
}
}
pub fn record_actual(&mut self, actual: Money) {
self.actual = Some(actual);
}
pub fn assign_cost_center(&mut self, center: &str) {
self.cost_center = Some(center.to_string());
}
#[must_use]
pub fn variance_cents(&self) -> Option<i64> {
self.actual
.as_ref()
.map(|a| a.amount_cents - self.estimated.amount_cents)
}
}
#[derive(Debug, Clone)]
pub struct BudgetLimit {
pub limit: Money,
pub warning_at: Money,
pub exceeded: bool,
}
impl BudgetLimit {
#[must_use]
pub fn new(limit: Money, warning_fraction: f64) -> Self {
let warning_cents = (limit.amount_cents as f64 * warning_fraction).round() as i64;
let warning_at = Money {
amount_cents: warning_cents,
currency: limit.currency.clone(),
};
Self {
limit,
warning_at,
exceeded: false,
}
}
pub fn evaluate(&mut self, spent: &Money) -> BudgetEvaluation {
if spent.exceeds(&self.limit) {
self.exceeded = true;
BudgetEvaluation::Exceeded
} else if spent.exceeds(&self.warning_at) {
BudgetEvaluation::Warning
} else {
BudgetEvaluation::Ok
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BudgetEvaluation {
Ok,
Warning,
Exceeded,
}
#[derive(Debug, Clone)]
pub struct CostCenter {
pub code: String,
pub description: String,
pub budget: Option<BudgetLimit>,
}
impl CostCenter {
#[must_use]
pub fn new(code: &str, description: &str) -> Self {
Self {
code: code.to_string(),
description: description.to_string(),
budget: None,
}
}
#[must_use]
pub fn with_budget(mut self, budget: BudgetLimit) -> Self {
self.budget = Some(budget);
self
}
}
#[derive(Debug, Default)]
pub struct CostLedger {
steps: Vec<StepCost>,
cost_centers: HashMap<String, CostCenter>,
currency: Option<Currency>,
}
impl CostLedger {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_currency(currency: Currency) -> Self {
Self {
currency: Some(currency),
..Default::default()
}
}
pub fn register_cost_center(&mut self, center: CostCenter) {
self.cost_centers.insert(center.code.clone(), center);
}
pub fn add_step(&mut self, step: StepCost) {
self.steps.push(step);
}
pub fn record_actual(&mut self, step_id: &str, actual: Money) -> bool {
for step in &mut self.steps {
if step.step_id == step_id {
step.record_actual(actual);
return true;
}
}
false
}
#[must_use]
pub fn total_estimated(&self) -> Option<Money> {
self.sum_money(self.steps.iter().map(|s| &s.estimated))
}
#[must_use]
pub fn total_actual(&self) -> Option<Money> {
let actuals: Vec<&Money> = self
.steps
.iter()
.filter_map(|s| s.actual.as_ref())
.collect();
if actuals.is_empty() {
return None;
}
self.sum_money(actuals.into_iter())
}
#[must_use]
pub fn cost_center_total(&self, center_code: &str) -> Option<Money> {
let relevant: Vec<&Money> = self
.steps
.iter()
.filter(|s| s.cost_center.as_deref() == Some(center_code))
.filter_map(|s| s.actual.as_ref())
.collect();
if relevant.is_empty() {
return None;
}
self.sum_money(relevant.into_iter())
}
fn sum_money<'a>(&self, iter: impl Iterator<Item = &'a Money>) -> Option<Money> {
let items: Vec<&'a Money> = iter.collect();
if items.is_empty() {
return None;
}
let currency = items[0].currency.clone();
if items.iter().any(|m| m.currency != currency) {
return None; }
let total_cents: i64 = items.iter().map(|m| m.amount_cents).sum();
Some(Money {
amount_cents: total_cents,
currency,
})
}
#[must_use]
pub fn step_count(&self) -> usize {
self.steps.len()
}
#[must_use]
pub fn cost_center_count(&self) -> usize {
self.cost_centers.len()
}
#[must_use]
pub fn evaluate_budget(&self, mut limit: BudgetLimit) -> Option<BudgetEvaluation> {
let actual = self.total_actual()?;
Some(limit.evaluate(&actual))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn usd(amount: f64) -> Money {
Money::from_decimal(amount, Currency::Usd)
}
fn eur(amount: f64) -> Money {
Money::from_decimal(amount, Currency::Eur)
}
#[test]
fn test_money_from_decimal() {
let m = usd(12.50);
assert_eq!(m.amount_cents, 1250);
assert!((m.as_decimal() - 12.50).abs() < 0.001);
}
#[test]
fn test_money_add_same_currency() {
let a = usd(10.00);
let b = usd(5.25);
let result = a.add(&b).expect("should succeed in test");
assert_eq!(result.amount_cents, 1525);
}
#[test]
fn test_money_add_different_currency() {
let a = usd(10.00);
let b = eur(5.00);
assert!(a.add(&b).is_none());
}
#[test]
fn test_money_exceeds() {
let spent = usd(150.00);
let budget = usd(100.00);
assert!(spent.exceeds(&budget));
let ok = usd(50.00);
assert!(!ok.exceeds(&budget));
}
#[test]
fn test_currency_code() {
assert_eq!(Currency::Usd.code(), "USD");
assert_eq!(Currency::Eur.code(), "EUR");
assert_eq!(Currency::Gbp.code(), "GBP");
assert_eq!(Currency::Jpy.code(), "JPY");
}
#[test]
fn test_step_cost_new_and_actual() {
let mut step = StepCost::new("s1", "Transcode", usd(20.00));
assert!(step.actual.is_none());
step.record_actual(usd(22.50));
assert!(step.actual.is_some());
assert_eq!(step.variance_cents(), Some(250));
}
#[test]
fn test_step_cost_assign_center() {
let mut step = StepCost::new("s1", "QC", usd(5.00));
step.assign_cost_center("CC-001");
assert_eq!(step.cost_center.as_deref(), Some("CC-001"));
}
#[test]
fn test_budget_limit_ok() {
let limit = BudgetLimit::new(usd(100.00), 0.8);
let mut bl = limit;
assert_eq!(bl.evaluate(&usd(50.00)), BudgetEvaluation::Ok);
}
#[test]
fn test_budget_limit_warning() {
let mut bl = BudgetLimit::new(usd(100.00), 0.8);
assert_eq!(bl.evaluate(&usd(85.00)), BudgetEvaluation::Warning);
}
#[test]
fn test_budget_limit_exceeded() {
let mut bl = BudgetLimit::new(usd(100.00), 0.8);
assert_eq!(bl.evaluate(&usd(110.00)), BudgetEvaluation::Exceeded);
assert!(bl.exceeded);
}
#[test]
fn test_ledger_total_estimated() {
let mut ledger = CostLedger::new();
ledger.add_step(StepCost::new("s1", "Step 1", usd(10.00)));
ledger.add_step(StepCost::new("s2", "Step 2", usd(20.00)));
let total = ledger.total_estimated().expect("should succeed in test");
assert_eq!(total.amount_cents, 3000);
}
#[test]
fn test_ledger_total_actual_none_if_no_actuals() {
let mut ledger = CostLedger::new();
ledger.add_step(StepCost::new("s1", "Step 1", usd(10.00)));
assert!(ledger.total_actual().is_none());
}
#[test]
fn test_ledger_record_actual() {
let mut ledger = CostLedger::new();
ledger.add_step(StepCost::new("s1", "Step 1", usd(10.00)));
assert!(ledger.record_actual("s1", usd(12.00)));
let total = ledger.total_actual().expect("should succeed in test");
assert_eq!(total.amount_cents, 1200);
}
#[test]
fn test_ledger_record_actual_missing() {
let mut ledger = CostLedger::new();
assert!(!ledger.record_actual("nonexistent", usd(5.00)));
}
#[test]
fn test_ledger_cost_center_registration() {
let mut ledger = CostLedger::new();
ledger.register_cost_center(CostCenter::new("CC-001", "Production"));
assert_eq!(ledger.cost_center_count(), 1);
}
#[test]
fn test_cost_center_total() {
let mut ledger = CostLedger::new();
let mut s1 = StepCost::new("s1", "Encode", usd(15.00));
s1.assign_cost_center("CC-001");
s1.record_actual(usd(16.00));
let mut s2 = StepCost::new("s2", "QC", usd(5.00));
s2.assign_cost_center("CC-002");
s2.record_actual(usd(5.50));
ledger.add_step(s1);
ledger.add_step(s2);
let cc1_total = ledger
.cost_center_total("CC-001")
.expect("should succeed in test");
assert_eq!(cc1_total.amount_cents, 1600);
}
#[test]
fn test_ledger_step_count() {
let mut ledger = CostLedger::new();
ledger.add_step(StepCost::new("s1", "Step 1", usd(10.00)));
ledger.add_step(StepCost::new("s2", "Step 2", usd(20.00)));
ledger.add_step(StepCost::new("s3", "Step 3", usd(30.00)));
assert_eq!(ledger.step_count(), 3);
}
#[test]
fn test_evaluate_budget_exceeded() {
let mut ledger = CostLedger::new();
let mut step = StepCost::new("s1", "Expensive", usd(200.00));
step.record_actual(usd(200.00));
ledger.add_step(step);
let result = ledger.evaluate_budget(BudgetLimit::new(usd(100.00), 0.8));
assert_eq!(result, Some(BudgetEvaluation::Exceeded));
}
#[test]
fn test_money_as_decimal_roundtrip() {
let m = Money::from_decimal(99.99, Currency::Gbp);
assert!((m.as_decimal() - 99.99).abs() < 0.001);
}
}