envelope_cli/models/
budget.rs

1//! Budget allocation model
2//!
3//! Tracks how much money is assigned to each category per budget period.
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9use super::ids::CategoryId;
10use super::money::Money;
11use super::period::BudgetPeriod;
12
13/// A budget allocation for a specific category in a specific period
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct BudgetAllocation {
16    /// The category this allocation is for
17    pub category_id: CategoryId,
18
19    /// The budget period
20    pub period: BudgetPeriod,
21
22    /// Amount budgeted/assigned to this category this period
23    pub budgeted: Money,
24
25    /// Amount carried over from the previous period (positive or negative)
26    pub carryover: Money,
27
28    /// Notes for this period's allocation
29    #[serde(default)]
30    pub notes: String,
31
32    /// When this allocation was created
33    pub created_at: DateTime<Utc>,
34
35    /// When this allocation was last modified
36    pub updated_at: DateTime<Utc>,
37}
38
39impl BudgetAllocation {
40    /// Create a new budget allocation
41    pub fn new(category_id: CategoryId, period: BudgetPeriod) -> Self {
42        let now = Utc::now();
43        Self {
44            category_id,
45            period,
46            budgeted: Money::zero(),
47            carryover: Money::zero(),
48            notes: String::new(),
49            created_at: now,
50            updated_at: now,
51        }
52    }
53
54    /// Create an allocation with an initial budget amount
55    pub fn with_budget(category_id: CategoryId, period: BudgetPeriod, budgeted: Money) -> Self {
56        let mut allocation = Self::new(category_id, period);
57        allocation.budgeted = budgeted;
58        allocation
59    }
60
61    /// Set the budgeted amount
62    pub fn set_budgeted(&mut self, amount: Money) {
63        self.budgeted = amount;
64        self.updated_at = Utc::now();
65    }
66
67    /// Add to the budgeted amount
68    pub fn add_budgeted(&mut self, amount: Money) {
69        self.budgeted += amount;
70        self.updated_at = Utc::now();
71    }
72
73    /// Set the carryover amount
74    pub fn set_carryover(&mut self, amount: Money) {
75        self.carryover = amount;
76        self.updated_at = Utc::now();
77    }
78
79    /// Get the total available in this category (budgeted + carryover)
80    /// Note: Activity (spending) must be subtracted by the caller who has transaction data
81    pub fn total_budgeted(&self) -> Money {
82        self.budgeted + self.carryover
83    }
84
85    /// Validate the allocation
86    pub fn validate(&self) -> Result<(), BudgetValidationError> {
87        // Budgeted amount cannot be negative
88        if self.budgeted.is_negative() {
89            return Err(BudgetValidationError::NegativeBudget);
90        }
91
92        Ok(())
93    }
94}
95
96impl fmt::Display for BudgetAllocation {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        write!(
99            f,
100            "{} budgeted: {} (carryover: {})",
101            self.period, self.budgeted, self.carryover
102        )
103    }
104}
105
106/// A summary of a category's budget status for a period
107#[derive(Debug, Clone)]
108pub struct CategoryBudgetSummary {
109    /// Category ID
110    pub category_id: CategoryId,
111
112    /// The period
113    pub period: BudgetPeriod,
114
115    /// Amount budgeted this period
116    pub budgeted: Money,
117
118    /// Amount carried over from previous period
119    pub carryover: Money,
120
121    /// Activity (sum of transactions) - negative means spending
122    pub activity: Money,
123
124    /// Available = budgeted + carryover + activity
125    pub available: Money,
126}
127
128impl CategoryBudgetSummary {
129    /// Create a new summary
130    pub fn new(
131        category_id: CategoryId,
132        period: BudgetPeriod,
133        budgeted: Money,
134        carryover: Money,
135        activity: Money,
136    ) -> Self {
137        let available = budgeted + carryover + activity;
138        Self {
139            category_id,
140            period,
141            budgeted,
142            carryover,
143            activity,
144            available,
145        }
146    }
147
148    /// Create an empty summary for a category (all zeros)
149    pub fn empty(category_id: CategoryId) -> Self {
150        Self {
151            category_id,
152            period: BudgetPeriod::current_month(),
153            budgeted: Money::zero(),
154            carryover: Money::zero(),
155            activity: Money::zero(),
156            available: Money::zero(),
157        }
158    }
159
160    /// Create from an allocation and activity amount
161    pub fn from_allocation(allocation: &BudgetAllocation, activity: Money) -> Self {
162        Self::new(
163            allocation.category_id,
164            allocation.period.clone(),
165            allocation.budgeted,
166            allocation.carryover,
167            activity,
168        )
169    }
170
171    /// Check if this category is overspent (available is negative)
172    pub fn is_overspent(&self) -> bool {
173        self.available.is_negative()
174    }
175
176    /// Check if this category is underfunded (budgeted < goal, if goal is set)
177    pub fn is_underfunded(&self, goal: Option<Money>) -> bool {
178        if let Some(goal_amount) = goal {
179            self.budgeted < goal_amount
180        } else {
181            false
182        }
183    }
184
185    /// Get the amount that would roll over to next period
186    pub fn rollover_amount(&self) -> Money {
187        self.available
188    }
189}
190
191impl fmt::Display for CategoryBudgetSummary {
192    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193        write!(
194            f,
195            "Budgeted: {} | Activity: {} | Available: {}",
196            self.budgeted, self.activity, self.available
197        )
198    }
199}
200
201/// Validation errors for budget allocations
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub enum BudgetValidationError {
204    NegativeBudget,
205}
206
207impl fmt::Display for BudgetValidationError {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        match self {
210            Self::NegativeBudget => write!(f, "Budget amount cannot be negative"),
211        }
212    }
213}
214
215impl std::error::Error for BudgetValidationError {}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    fn test_category_id() -> CategoryId {
222        CategoryId::new()
223    }
224
225    fn test_period() -> BudgetPeriod {
226        BudgetPeriod::monthly(2025, 1)
227    }
228
229    #[test]
230    fn test_new_allocation() {
231        let category_id = test_category_id();
232        let period = test_period();
233        let allocation = BudgetAllocation::new(category_id, period.clone());
234
235        assert_eq!(allocation.category_id, category_id);
236        assert_eq!(allocation.period, period);
237        assert_eq!(allocation.budgeted, Money::zero());
238        assert_eq!(allocation.carryover, Money::zero());
239    }
240
241    #[test]
242    fn test_with_budget() {
243        let category_id = test_category_id();
244        let period = test_period();
245        let allocation =
246            BudgetAllocation::with_budget(category_id, period, Money::from_cents(50000));
247
248        assert_eq!(allocation.budgeted.cents(), 50000);
249    }
250
251    #[test]
252    fn test_total_budgeted() {
253        let category_id = test_category_id();
254        let period = test_period();
255        let mut allocation = BudgetAllocation::new(category_id, period);
256        allocation.budgeted = Money::from_cents(50000);
257        allocation.carryover = Money::from_cents(10000);
258
259        assert_eq!(allocation.total_budgeted().cents(), 60000);
260    }
261
262    #[test]
263    fn test_negative_carryover() {
264        let category_id = test_category_id();
265        let period = test_period();
266        let mut allocation = BudgetAllocation::new(category_id, period);
267        allocation.budgeted = Money::from_cents(50000);
268        allocation.carryover = Money::from_cents(-20000); // Overspent last period
269
270        assert_eq!(allocation.total_budgeted().cents(), 30000);
271    }
272
273    #[test]
274    fn test_validation() {
275        let category_id = test_category_id();
276        let period = test_period();
277        let mut allocation = BudgetAllocation::new(category_id, period);
278
279        allocation.budgeted = Money::from_cents(50000);
280        assert!(allocation.validate().is_ok());
281
282        allocation.budgeted = Money::from_cents(-100);
283        assert_eq!(
284            allocation.validate(),
285            Err(BudgetValidationError::NegativeBudget)
286        );
287    }
288
289    #[test]
290    fn test_category_summary() {
291        let category_id = test_category_id();
292        let period = test_period();
293        let budgeted = Money::from_cents(50000);
294        let carryover = Money::from_cents(10000);
295        let activity = Money::from_cents(-30000); // Spent $300
296
297        let summary =
298            CategoryBudgetSummary::new(category_id, period, budgeted, carryover, activity);
299
300        assert_eq!(summary.budgeted.cents(), 50000);
301        assert_eq!(summary.carryover.cents(), 10000);
302        assert_eq!(summary.activity.cents(), -30000);
303        assert_eq!(summary.available.cents(), 30000); // 500 + 100 - 300 = 300
304        assert!(!summary.is_overspent());
305    }
306
307    #[test]
308    fn test_overspent_summary() {
309        let category_id = test_category_id();
310        let period = test_period();
311        let budgeted = Money::from_cents(50000);
312        let carryover = Money::zero();
313        let activity = Money::from_cents(-60000); // Overspent by $100
314
315        let summary =
316            CategoryBudgetSummary::new(category_id, period, budgeted, carryover, activity);
317
318        assert!(summary.is_overspent());
319        assert_eq!(summary.available.cents(), -10000);
320        assert_eq!(summary.rollover_amount().cents(), -10000);
321    }
322
323    #[test]
324    fn test_serialization() {
325        let category_id = test_category_id();
326        let period = test_period();
327        let allocation =
328            BudgetAllocation::with_budget(category_id, period, Money::from_cents(50000));
329
330        let json = serde_json::to_string(&allocation).unwrap();
331        let deserialized: BudgetAllocation = serde_json::from_str(&json).unwrap();
332        assert_eq!(allocation.category_id, deserialized.category_id);
333        assert_eq!(allocation.budgeted, deserialized.budgeted);
334    }
335}