Skip to main content

oximedia_workflow/
cost_tracking.rs

1//! Workflow cost tracking: per-step costs, budget limits, and cost center allocation.
2
3#![allow(dead_code)]
4#![allow(clippy::cast_precision_loss)]
5
6use std::collections::HashMap;
7
8/// Currency used for cost tracking.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum Currency {
11    /// US Dollars.
12    Usd,
13    /// Euros.
14    Eur,
15    /// British Pounds.
16    Gbp,
17    /// Japanese Yen.
18    Jpy,
19}
20
21impl Currency {
22    /// Return the ISO 4217 currency code.
23    #[must_use]
24    pub fn code(&self) -> &'static str {
25        match self {
26            Self::Usd => "USD",
27            Self::Eur => "EUR",
28            Self::Gbp => "GBP",
29            Self::Jpy => "JPY",
30        }
31    }
32}
33
34/// A monetary amount with associated currency.
35#[derive(Debug, Clone)]
36pub struct Money {
37    /// Amount in the smallest unit (e.g., cents for USD).
38    pub amount_cents: i64,
39    /// Currency of the amount.
40    pub currency: Currency,
41}
42
43impl Money {
44    /// Create a new money value from a decimal amount (e.g., 12.50 USD).
45    #[must_use]
46    pub fn from_decimal(amount: f64, currency: Currency) -> Self {
47        Self {
48            amount_cents: (amount * 100.0).round() as i64,
49            currency,
50        }
51    }
52
53    /// Return the decimal representation.
54    #[must_use]
55    pub fn as_decimal(&self) -> f64 {
56        self.amount_cents as f64 / 100.0
57    }
58
59    /// Add another money value (must be same currency).
60    #[must_use]
61    pub fn add(&self, other: &Self) -> Option<Self> {
62        if self.currency != other.currency {
63            return None;
64        }
65        Some(Self {
66            amount_cents: self.amount_cents + other.amount_cents,
67            currency: self.currency.clone(),
68        })
69    }
70
71    /// Check whether the amount exceeds a budget limit.
72    #[must_use]
73    pub fn exceeds(&self, budget: &Self) -> bool {
74        self.currency == budget.currency && self.amount_cents > budget.amount_cents
75    }
76}
77
78/// A cost entry for a single workflow step.
79#[derive(Debug, Clone)]
80pub struct StepCost {
81    /// Step identifier.
82    pub step_id: String,
83    /// Human-readable step name.
84    pub step_name: String,
85    /// Estimated cost before execution.
86    pub estimated: Money,
87    /// Actual cost after execution (None if not yet run).
88    pub actual: Option<Money>,
89    /// Cost center this step is billed to.
90    pub cost_center: Option<String>,
91}
92
93impl StepCost {
94    /// Create a new step cost with an estimate.
95    #[must_use]
96    pub fn new(step_id: &str, step_name: &str, estimated: Money) -> Self {
97        Self {
98            step_id: step_id.to_string(),
99            step_name: step_name.to_string(),
100            estimated,
101            actual: None,
102            cost_center: None,
103        }
104    }
105
106    /// Record the actual cost after the step executes.
107    pub fn record_actual(&mut self, actual: Money) {
108        self.actual = Some(actual);
109    }
110
111    /// Assign to a cost center.
112    pub fn assign_cost_center(&mut self, center: &str) {
113        self.cost_center = Some(center.to_string());
114    }
115
116    /// Variance between actual and estimated (positive = over-budget).
117    #[must_use]
118    pub fn variance_cents(&self) -> Option<i64> {
119        self.actual
120            .as_ref()
121            .map(|a| a.amount_cents - self.estimated.amount_cents)
122    }
123}
124
125/// Budget limit configuration.
126#[derive(Debug, Clone)]
127pub struct BudgetLimit {
128    /// Maximum allowed spend.
129    pub limit: Money,
130    /// Warning threshold (spend at which to alert).
131    pub warning_at: Money,
132    /// Whether the budget has been exceeded.
133    pub exceeded: bool,
134}
135
136impl BudgetLimit {
137    /// Create a new budget limit.
138    #[must_use]
139    pub fn new(limit: Money, warning_fraction: f64) -> Self {
140        let warning_cents = (limit.amount_cents as f64 * warning_fraction).round() as i64;
141        let warning_at = Money {
142            amount_cents: warning_cents,
143            currency: limit.currency.clone(),
144        };
145        Self {
146            limit,
147            warning_at,
148            exceeded: false,
149        }
150    }
151
152    /// Check if a given spend amount exceeds or warns the budget.
153    pub fn evaluate(&mut self, spent: &Money) -> BudgetEvaluation {
154        if spent.exceeds(&self.limit) {
155            self.exceeded = true;
156            BudgetEvaluation::Exceeded
157        } else if spent.exceeds(&self.warning_at) {
158            BudgetEvaluation::Warning
159        } else {
160            BudgetEvaluation::Ok
161        }
162    }
163}
164
165/// Result of a budget evaluation.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum BudgetEvaluation {
168    /// Spend is within budget.
169    Ok,
170    /// Spend is above warning threshold but within budget.
171    Warning,
172    /// Budget has been exceeded.
173    Exceeded,
174}
175
176/// Cost center definition.
177#[derive(Debug, Clone)]
178pub struct CostCenter {
179    /// Cost center code.
180    pub code: String,
181    /// Human-readable description.
182    pub description: String,
183    /// Optional budget limit for this cost center.
184    pub budget: Option<BudgetLimit>,
185}
186
187impl CostCenter {
188    /// Create a new cost center without a budget.
189    #[must_use]
190    pub fn new(code: &str, description: &str) -> Self {
191        Self {
192            code: code.to_string(),
193            description: description.to_string(),
194            budget: None,
195        }
196    }
197
198    /// Set the budget for this cost center.
199    #[must_use]
200    pub fn with_budget(mut self, budget: BudgetLimit) -> Self {
201        self.budget = Some(budget);
202        self
203    }
204}
205
206/// Workflow cost ledger: aggregates all step costs for a workflow.
207#[derive(Debug, Default)]
208pub struct CostLedger {
209    /// All step cost entries.
210    steps: Vec<StepCost>,
211    /// Registered cost centers.
212    cost_centers: HashMap<String, CostCenter>,
213    /// Default currency for totals.
214    currency: Option<Currency>,
215}
216
217impl CostLedger {
218    /// Create a new empty ledger.
219    #[must_use]
220    pub fn new() -> Self {
221        Self::default()
222    }
223
224    /// Create a ledger with a default currency.
225    #[must_use]
226    pub fn with_currency(currency: Currency) -> Self {
227        Self {
228            currency: Some(currency),
229            ..Default::default()
230        }
231    }
232
233    /// Register a cost center.
234    pub fn register_cost_center(&mut self, center: CostCenter) {
235        self.cost_centers.insert(center.code.clone(), center);
236    }
237
238    /// Add a step cost entry.
239    pub fn add_step(&mut self, step: StepCost) {
240        self.steps.push(step);
241    }
242
243    /// Record actual cost for a step.
244    pub fn record_actual(&mut self, step_id: &str, actual: Money) -> bool {
245        for step in &mut self.steps {
246            if step.step_id == step_id {
247                step.record_actual(actual);
248                return true;
249            }
250        }
251        false
252    }
253
254    /// Total estimated cost (returns None if any currencies mismatch or no steps).
255    #[must_use]
256    pub fn total_estimated(&self) -> Option<Money> {
257        self.sum_money(self.steps.iter().map(|s| &s.estimated))
258    }
259
260    /// Total actual cost (steps without actual cost are skipped).
261    #[must_use]
262    pub fn total_actual(&self) -> Option<Money> {
263        let actuals: Vec<&Money> = self
264            .steps
265            .iter()
266            .filter_map(|s| s.actual.as_ref())
267            .collect();
268        if actuals.is_empty() {
269            return None;
270        }
271        self.sum_money(actuals.into_iter())
272    }
273
274    /// Costs allocated to a specific cost center.
275    #[must_use]
276    pub fn cost_center_total(&self, center_code: &str) -> Option<Money> {
277        let relevant: Vec<&Money> = self
278            .steps
279            .iter()
280            .filter(|s| s.cost_center.as_deref() == Some(center_code))
281            .filter_map(|s| s.actual.as_ref())
282            .collect();
283        if relevant.is_empty() {
284            return None;
285        }
286        self.sum_money(relevant.into_iter())
287    }
288
289    fn sum_money<'a>(&self, iter: impl Iterator<Item = &'a Money>) -> Option<Money> {
290        let items: Vec<&'a Money> = iter.collect();
291        if items.is_empty() {
292            return None;
293        }
294        let currency = items[0].currency.clone();
295        if items.iter().any(|m| m.currency != currency) {
296            return None; // Currency mismatch
297        }
298        let total_cents: i64 = items.iter().map(|m| m.amount_cents).sum();
299        Some(Money {
300            amount_cents: total_cents,
301            currency,
302        })
303    }
304
305    /// Number of step entries.
306    #[must_use]
307    pub fn step_count(&self) -> usize {
308        self.steps.len()
309    }
310
311    /// Number of registered cost centers.
312    #[must_use]
313    pub fn cost_center_count(&self) -> usize {
314        self.cost_centers.len()
315    }
316
317    /// Evaluate total actual spend against a budget limit.
318    #[must_use]
319    pub fn evaluate_budget(&self, mut limit: BudgetLimit) -> Option<BudgetEvaluation> {
320        let actual = self.total_actual()?;
321        Some(limit.evaluate(&actual))
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn usd(amount: f64) -> Money {
330        Money::from_decimal(amount, Currency::Usd)
331    }
332
333    fn eur(amount: f64) -> Money {
334        Money::from_decimal(amount, Currency::Eur)
335    }
336
337    #[test]
338    fn test_money_from_decimal() {
339        let m = usd(12.50);
340        assert_eq!(m.amount_cents, 1250);
341        assert!((m.as_decimal() - 12.50).abs() < 0.001);
342    }
343
344    #[test]
345    fn test_money_add_same_currency() {
346        let a = usd(10.00);
347        let b = usd(5.25);
348        let result = a.add(&b).expect("should succeed in test");
349        assert_eq!(result.amount_cents, 1525);
350    }
351
352    #[test]
353    fn test_money_add_different_currency() {
354        let a = usd(10.00);
355        let b = eur(5.00);
356        assert!(a.add(&b).is_none());
357    }
358
359    #[test]
360    fn test_money_exceeds() {
361        let spent = usd(150.00);
362        let budget = usd(100.00);
363        assert!(spent.exceeds(&budget));
364        let ok = usd(50.00);
365        assert!(!ok.exceeds(&budget));
366    }
367
368    #[test]
369    fn test_currency_code() {
370        assert_eq!(Currency::Usd.code(), "USD");
371        assert_eq!(Currency::Eur.code(), "EUR");
372        assert_eq!(Currency::Gbp.code(), "GBP");
373        assert_eq!(Currency::Jpy.code(), "JPY");
374    }
375
376    #[test]
377    fn test_step_cost_new_and_actual() {
378        let mut step = StepCost::new("s1", "Transcode", usd(20.00));
379        assert!(step.actual.is_none());
380        step.record_actual(usd(22.50));
381        assert!(step.actual.is_some());
382        assert_eq!(step.variance_cents(), Some(250));
383    }
384
385    #[test]
386    fn test_step_cost_assign_center() {
387        let mut step = StepCost::new("s1", "QC", usd(5.00));
388        step.assign_cost_center("CC-001");
389        assert_eq!(step.cost_center.as_deref(), Some("CC-001"));
390    }
391
392    #[test]
393    fn test_budget_limit_ok() {
394        let limit = BudgetLimit::new(usd(100.00), 0.8);
395        let mut bl = limit;
396        assert_eq!(bl.evaluate(&usd(50.00)), BudgetEvaluation::Ok);
397    }
398
399    #[test]
400    fn test_budget_limit_warning() {
401        let mut bl = BudgetLimit::new(usd(100.00), 0.8);
402        assert_eq!(bl.evaluate(&usd(85.00)), BudgetEvaluation::Warning);
403    }
404
405    #[test]
406    fn test_budget_limit_exceeded() {
407        let mut bl = BudgetLimit::new(usd(100.00), 0.8);
408        assert_eq!(bl.evaluate(&usd(110.00)), BudgetEvaluation::Exceeded);
409        assert!(bl.exceeded);
410    }
411
412    #[test]
413    fn test_ledger_total_estimated() {
414        let mut ledger = CostLedger::new();
415        ledger.add_step(StepCost::new("s1", "Step 1", usd(10.00)));
416        ledger.add_step(StepCost::new("s2", "Step 2", usd(20.00)));
417        let total = ledger.total_estimated().expect("should succeed in test");
418        assert_eq!(total.amount_cents, 3000);
419    }
420
421    #[test]
422    fn test_ledger_total_actual_none_if_no_actuals() {
423        let mut ledger = CostLedger::new();
424        ledger.add_step(StepCost::new("s1", "Step 1", usd(10.00)));
425        assert!(ledger.total_actual().is_none());
426    }
427
428    #[test]
429    fn test_ledger_record_actual() {
430        let mut ledger = CostLedger::new();
431        ledger.add_step(StepCost::new("s1", "Step 1", usd(10.00)));
432        assert!(ledger.record_actual("s1", usd(12.00)));
433        let total = ledger.total_actual().expect("should succeed in test");
434        assert_eq!(total.amount_cents, 1200);
435    }
436
437    #[test]
438    fn test_ledger_record_actual_missing() {
439        let mut ledger = CostLedger::new();
440        assert!(!ledger.record_actual("nonexistent", usd(5.00)));
441    }
442
443    #[test]
444    fn test_ledger_cost_center_registration() {
445        let mut ledger = CostLedger::new();
446        ledger.register_cost_center(CostCenter::new("CC-001", "Production"));
447        assert_eq!(ledger.cost_center_count(), 1);
448    }
449
450    #[test]
451    fn test_cost_center_total() {
452        let mut ledger = CostLedger::new();
453        let mut s1 = StepCost::new("s1", "Encode", usd(15.00));
454        s1.assign_cost_center("CC-001");
455        s1.record_actual(usd(16.00));
456        let mut s2 = StepCost::new("s2", "QC", usd(5.00));
457        s2.assign_cost_center("CC-002");
458        s2.record_actual(usd(5.50));
459        ledger.add_step(s1);
460        ledger.add_step(s2);
461        let cc1_total = ledger
462            .cost_center_total("CC-001")
463            .expect("should succeed in test");
464        assert_eq!(cc1_total.amount_cents, 1600);
465    }
466
467    #[test]
468    fn test_ledger_step_count() {
469        let mut ledger = CostLedger::new();
470        ledger.add_step(StepCost::new("s1", "Step 1", usd(10.00)));
471        ledger.add_step(StepCost::new("s2", "Step 2", usd(20.00)));
472        ledger.add_step(StepCost::new("s3", "Step 3", usd(30.00)));
473        assert_eq!(ledger.step_count(), 3);
474    }
475
476    #[test]
477    fn test_evaluate_budget_exceeded() {
478        let mut ledger = CostLedger::new();
479        let mut step = StepCost::new("s1", "Expensive", usd(200.00));
480        step.record_actual(usd(200.00));
481        ledger.add_step(step);
482        let result = ledger.evaluate_budget(BudgetLimit::new(usd(100.00), 0.8));
483        assert_eq!(result, Some(BudgetEvaluation::Exceeded));
484    }
485
486    #[test]
487    fn test_money_as_decimal_roundtrip() {
488        let m = Money::from_decimal(99.99, Currency::Gbp);
489        assert!((m.as_decimal() - 99.99).abs() < 0.001);
490    }
491}