1#![allow(dead_code)]
4#![allow(clippy::cast_precision_loss)]
5
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum Currency {
11 Usd,
13 Eur,
15 Gbp,
17 Jpy,
19}
20
21impl Currency {
22 #[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#[derive(Debug, Clone)]
36pub struct Money {
37 pub amount_cents: i64,
39 pub currency: Currency,
41}
42
43impl Money {
44 #[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 #[must_use]
55 pub fn as_decimal(&self) -> f64 {
56 self.amount_cents as f64 / 100.0
57 }
58
59 #[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 #[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#[derive(Debug, Clone)]
80pub struct StepCost {
81 pub step_id: String,
83 pub step_name: String,
85 pub estimated: Money,
87 pub actual: Option<Money>,
89 pub cost_center: Option<String>,
91}
92
93impl StepCost {
94 #[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 pub fn record_actual(&mut self, actual: Money) {
108 self.actual = Some(actual);
109 }
110
111 pub fn assign_cost_center(&mut self, center: &str) {
113 self.cost_center = Some(center.to_string());
114 }
115
116 #[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#[derive(Debug, Clone)]
127pub struct BudgetLimit {
128 pub limit: Money,
130 pub warning_at: Money,
132 pub exceeded: bool,
134}
135
136impl BudgetLimit {
137 #[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 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#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum BudgetEvaluation {
168 Ok,
170 Warning,
172 Exceeded,
174}
175
176#[derive(Debug, Clone)]
178pub struct CostCenter {
179 pub code: String,
181 pub description: String,
183 pub budget: Option<BudgetLimit>,
185}
186
187impl CostCenter {
188 #[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 #[must_use]
200 pub fn with_budget(mut self, budget: BudgetLimit) -> Self {
201 self.budget = Some(budget);
202 self
203 }
204}
205
206#[derive(Debug, Default)]
208pub struct CostLedger {
209 steps: Vec<StepCost>,
211 cost_centers: HashMap<String, CostCenter>,
213 currency: Option<Currency>,
215}
216
217impl CostLedger {
218 #[must_use]
220 pub fn new() -> Self {
221 Self::default()
222 }
223
224 #[must_use]
226 pub fn with_currency(currency: Currency) -> Self {
227 Self {
228 currency: Some(currency),
229 ..Default::default()
230 }
231 }
232
233 pub fn register_cost_center(&mut self, center: CostCenter) {
235 self.cost_centers.insert(center.code.clone(), center);
236 }
237
238 pub fn add_step(&mut self, step: StepCost) {
240 self.steps.push(step);
241 }
242
243 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 #[must_use]
256 pub fn total_estimated(&self) -> Option<Money> {
257 self.sum_money(self.steps.iter().map(|s| &s.estimated))
258 }
259
260 #[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 #[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; }
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 #[must_use]
307 pub fn step_count(&self) -> usize {
308 self.steps.len()
309 }
310
311 #[must_use]
313 pub fn cost_center_count(&self) -> usize {
314 self.cost_centers.len()
315 }
316
317 #[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}