envelope_cli/models/
income.rs

1//! Income expectation model
2//!
3//! Tracks expected income per budget period, allowing users to see
4//! when they're budgeting more than they expect to earn.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use super::ids::IncomeId;
10use super::money::Money;
11use super::period::BudgetPeriod;
12
13/// Validation errors for income expectations
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum IncomeValidationError {
16    NegativeAmount,
17}
18
19impl std::fmt::Display for IncomeValidationError {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            Self::NegativeAmount => write!(f, "Expected income cannot be negative"),
23        }
24    }
25}
26
27impl std::error::Error for IncomeValidationError {}
28
29/// Expected income for a budget period
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct IncomeExpectation {
32    pub id: IncomeId,
33    pub period: BudgetPeriod,
34    pub expected_amount: Money,
35    #[serde(default)]
36    pub notes: String,
37    pub created_at: DateTime<Utc>,
38    pub updated_at: DateTime<Utc>,
39}
40
41impl IncomeExpectation {
42    /// Create a new income expectation
43    pub fn new(period: BudgetPeriod, expected_amount: Money) -> Self {
44        let now = Utc::now();
45        Self {
46            id: IncomeId::new(),
47            period,
48            expected_amount,
49            notes: String::new(),
50            created_at: now,
51            updated_at: now,
52        }
53    }
54
55    /// Set the expected amount
56    pub fn set_expected_amount(&mut self, amount: Money) {
57        self.expected_amount = amount;
58        self.updated_at = Utc::now();
59    }
60
61    /// Set notes
62    pub fn set_notes(&mut self, notes: impl Into<String>) {
63        self.notes = notes.into();
64        self.updated_at = Utc::now();
65    }
66
67    /// Validate the income expectation
68    pub fn validate(&self) -> Result<(), IncomeValidationError> {
69        if self.expected_amount.is_negative() {
70            return Err(IncomeValidationError::NegativeAmount);
71        }
72        Ok(())
73    }
74
75    /// Check if a budgeted amount exceeds expected income
76    pub fn is_over_budget(&self, total_budgeted: Money) -> bool {
77        total_budgeted > self.expected_amount
78    }
79
80    /// Get the difference between expected income and budgeted amount
81    /// Positive = under budget (good), Negative = over budget (warning)
82    pub fn budget_difference(&self, total_budgeted: Money) -> Money {
83        self.expected_amount - total_budgeted
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn test_new_income_expectation() {
93        let period = BudgetPeriod::monthly(2025, 1);
94        let income = IncomeExpectation::new(period.clone(), Money::from_cents(500000));
95
96        assert_eq!(income.period, period);
97        assert_eq!(income.expected_amount.cents(), 500000);
98        assert!(income.notes.is_empty());
99    }
100
101    #[test]
102    fn test_validation_negative_amount() {
103        let period = BudgetPeriod::monthly(2025, 1);
104        let income = IncomeExpectation::new(period, Money::from_cents(-100));
105
106        assert!(matches!(
107            income.validate(),
108            Err(IncomeValidationError::NegativeAmount)
109        ));
110    }
111
112    #[test]
113    fn test_over_budget_detection() {
114        let period = BudgetPeriod::monthly(2025, 1);
115        let income = IncomeExpectation::new(period, Money::from_cents(500000)); // $5000
116
117        // Under budget
118        assert!(!income.is_over_budget(Money::from_cents(400000))); // $4000
119
120        // Exactly at budget
121        assert!(!income.is_over_budget(Money::from_cents(500000))); // $5000
122
123        // Over budget
124        assert!(income.is_over_budget(Money::from_cents(600000))); // $6000
125    }
126
127    #[test]
128    fn test_budget_difference() {
129        let period = BudgetPeriod::monthly(2025, 1);
130        let income = IncomeExpectation::new(period, Money::from_cents(500000)); // $5000
131
132        // Under budget by $1000
133        let diff = income.budget_difference(Money::from_cents(400000));
134        assert_eq!(diff.cents(), 100000);
135
136        // Over budget by $1000
137        let diff = income.budget_difference(Money::from_cents(600000));
138        assert_eq!(diff.cents(), -100000);
139    }
140
141    #[test]
142    fn test_set_expected_amount() {
143        let period = BudgetPeriod::monthly(2025, 1);
144        let mut income = IncomeExpectation::new(period, Money::from_cents(500000));
145        let original_updated = income.updated_at;
146
147        // Small delay to ensure timestamp changes
148        std::thread::sleep(std::time::Duration::from_millis(10));
149
150        income.set_expected_amount(Money::from_cents(600000));
151        assert_eq!(income.expected_amount.cents(), 600000);
152        assert!(income.updated_at >= original_updated);
153    }
154
155    #[test]
156    fn test_set_notes() {
157        let period = BudgetPeriod::monthly(2025, 1);
158        let mut income = IncomeExpectation::new(period, Money::from_cents(500000));
159
160        income.set_notes("Includes bonus");
161        assert_eq!(income.notes, "Includes bonus");
162    }
163
164    #[test]
165    fn test_serialization() {
166        let period = BudgetPeriod::monthly(2025, 1);
167        let income = IncomeExpectation::new(period, Money::from_cents(500000));
168
169        let json = serde_json::to_string(&income).unwrap();
170        let deserialized: IncomeExpectation = serde_json::from_str(&json).unwrap();
171
172        assert_eq!(income.id, deserialized.id);
173        assert_eq!(income.period, deserialized.period);
174        assert_eq!(income.expected_amount, deserialized.expected_amount);
175    }
176}