envelope_cli/models/
category.rs

1//! Category and CategoryGroup models
2//!
3//! Categories are organized into groups for display and organization.
4//! Each category tracks budget allocations and spending.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10use super::ids::{CategoryGroupId, CategoryId};
11
12/// A group of related categories (e.g., "Bills", "Needs", "Wants")
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CategoryGroup {
15    /// Unique identifier
16    pub id: CategoryGroupId,
17
18    /// Group name
19    pub name: String,
20
21    /// Sort order for display
22    pub sort_order: i32,
23
24    /// Whether this group is hidden (collapsed in UI)
25    #[serde(default)]
26    pub hidden: bool,
27
28    /// When the group was created
29    pub created_at: DateTime<Utc>,
30
31    /// When the group was last modified
32    pub updated_at: DateTime<Utc>,
33}
34
35impl CategoryGroup {
36    /// Create a new category group
37    pub fn new(name: impl Into<String>) -> Self {
38        let now = Utc::now();
39        Self {
40            id: CategoryGroupId::new(),
41            name: name.into(),
42            sort_order: 0,
43            hidden: false,
44            created_at: now,
45            updated_at: now,
46        }
47    }
48
49    /// Create a new group with a specific sort order
50    pub fn with_sort_order(name: impl Into<String>, sort_order: i32) -> Self {
51        let mut group = Self::new(name);
52        group.sort_order = sort_order;
53        group
54    }
55
56    /// Validate the group
57    pub fn validate(&self) -> Result<(), CategoryValidationError> {
58        if self.name.trim().is_empty() {
59            return Err(CategoryValidationError::EmptyName);
60        }
61
62        if self.name.len() > 50 {
63            return Err(CategoryValidationError::NameTooLong(self.name.len()));
64        }
65
66        Ok(())
67    }
68}
69
70impl fmt::Display for CategoryGroup {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        write!(f, "{}", self.name)
73    }
74}
75
76/// A budget category within a group
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct Category {
79    /// Unique identifier
80    pub id: CategoryId,
81
82    /// Category name
83    pub name: String,
84
85    /// The group this category belongs to
86    pub group_id: CategoryGroupId,
87
88    /// Sort order within the group
89    pub sort_order: i32,
90
91    /// Whether this category is hidden
92    #[serde(default)]
93    pub hidden: bool,
94
95    /// Goal amount per period (optional)
96    pub goal_amount: Option<i64>,
97
98    /// Notes about this category
99    #[serde(default)]
100    pub notes: String,
101
102    /// When the category was created
103    pub created_at: DateTime<Utc>,
104
105    /// When the category was last modified
106    pub updated_at: DateTime<Utc>,
107}
108
109impl Category {
110    /// Create a new category
111    pub fn new(name: impl Into<String>, group_id: CategoryGroupId) -> Self {
112        let now = Utc::now();
113        Self {
114            id: CategoryId::new(),
115            name: name.into(),
116            group_id,
117            sort_order: 0,
118            hidden: false,
119            goal_amount: None,
120            notes: String::new(),
121            created_at: now,
122            updated_at: now,
123        }
124    }
125
126    /// Create a new category with a specific sort order
127    pub fn with_sort_order(
128        name: impl Into<String>,
129        group_id: CategoryGroupId,
130        sort_order: i32,
131    ) -> Self {
132        let mut category = Self::new(name, group_id);
133        category.sort_order = sort_order;
134        category
135    }
136
137    /// Set a goal amount
138    pub fn set_goal(&mut self, amount: i64) {
139        self.goal_amount = Some(amount);
140        self.updated_at = Utc::now();
141    }
142
143    /// Clear the goal
144    pub fn clear_goal(&mut self) {
145        self.goal_amount = None;
146        self.updated_at = Utc::now();
147    }
148
149    /// Move to a different group
150    pub fn move_to_group(&mut self, group_id: CategoryGroupId) {
151        self.group_id = group_id;
152        self.updated_at = Utc::now();
153    }
154
155    /// Validate the category
156    pub fn validate(&self) -> Result<(), CategoryValidationError> {
157        if self.name.trim().is_empty() {
158            return Err(CategoryValidationError::EmptyName);
159        }
160
161        if self.name.len() > 50 {
162            return Err(CategoryValidationError::NameTooLong(self.name.len()));
163        }
164
165        if let Some(goal) = self.goal_amount {
166            if goal < 0 {
167                return Err(CategoryValidationError::NegativeGoal);
168            }
169        }
170
171        Ok(())
172    }
173}
174
175impl fmt::Display for Category {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        write!(f, "{}", self.name)
178    }
179}
180
181/// Default category groups for new budgets
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum DefaultCategoryGroup {
184    Bills,
185    Needs,
186    Wants,
187    Savings,
188}
189
190impl DefaultCategoryGroup {
191    /// Get all default groups in order
192    pub fn all() -> &'static [Self] {
193        &[Self::Bills, Self::Needs, Self::Wants, Self::Savings]
194    }
195
196    /// Get the name for this default group
197    pub fn name(&self) -> &'static str {
198        match self {
199            Self::Bills => "Bills",
200            Self::Needs => "Needs",
201            Self::Wants => "Wants",
202            Self::Savings => "Savings",
203        }
204    }
205
206    /// Create a CategoryGroup from this default
207    pub fn to_group(&self, sort_order: i32) -> CategoryGroup {
208        CategoryGroup::with_sort_order(self.name(), sort_order)
209    }
210}
211
212/// Validation errors for categories
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum CategoryValidationError {
215    EmptyName,
216    NameTooLong(usize),
217    NegativeGoal,
218}
219
220impl fmt::Display for CategoryValidationError {
221    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222        match self {
223            Self::EmptyName => write!(f, "Category name cannot be empty"),
224            Self::NameTooLong(len) => {
225                write!(f, "Category name too long ({} chars, max 50)", len)
226            }
227            Self::NegativeGoal => write!(f, "Goal amount cannot be negative"),
228        }
229    }
230}
231
232impl std::error::Error for CategoryValidationError {}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_new_group() {
240        let group = CategoryGroup::new("Bills");
241        assert_eq!(group.name, "Bills");
242        assert_eq!(group.sort_order, 0);
243        assert!(!group.hidden);
244    }
245
246    #[test]
247    fn test_new_category() {
248        let group = CategoryGroup::new("Bills");
249        let category = Category::new("Rent", group.id);
250
251        assert_eq!(category.name, "Rent");
252        assert_eq!(category.group_id, group.id);
253        assert!(!category.hidden);
254        assert!(category.goal_amount.is_none());
255    }
256
257    #[test]
258    fn test_category_goal() {
259        let group = CategoryGroup::new("Savings");
260        let mut category = Category::new("Emergency Fund", group.id);
261
262        category.set_goal(100000); // $1000.00
263        assert_eq!(category.goal_amount, Some(100000));
264
265        category.clear_goal();
266        assert!(category.goal_amount.is_none());
267    }
268
269    #[test]
270    fn test_group_validation() {
271        let mut group = CategoryGroup::new("Valid");
272        assert!(group.validate().is_ok());
273
274        group.name = String::new();
275        assert_eq!(group.validate(), Err(CategoryValidationError::EmptyName));
276
277        group.name = "a".repeat(51);
278        assert!(matches!(
279            group.validate(),
280            Err(CategoryValidationError::NameTooLong(_))
281        ));
282    }
283
284    #[test]
285    fn test_category_validation() {
286        let group = CategoryGroup::new("Test");
287        let mut category = Category::new("Valid", group.id);
288        assert!(category.validate().is_ok());
289
290        category.name = String::new();
291        assert_eq!(category.validate(), Err(CategoryValidationError::EmptyName));
292
293        category.name = "Valid".to_string();
294        category.goal_amount = Some(-100);
295        assert_eq!(
296            category.validate(),
297            Err(CategoryValidationError::NegativeGoal)
298        );
299    }
300
301    #[test]
302    fn test_default_groups() {
303        let defaults = DefaultCategoryGroup::all();
304        assert_eq!(defaults.len(), 4);
305        assert_eq!(defaults[0].name(), "Bills");
306        assert_eq!(defaults[1].name(), "Needs");
307    }
308
309    #[test]
310    fn test_move_category() {
311        let group1 = CategoryGroup::new("Group 1");
312        let group2 = CategoryGroup::new("Group 2");
313        let mut category = Category::new("Test", group1.id);
314
315        assert_eq!(category.group_id, group1.id);
316
317        category.move_to_group(group2.id);
318        assert_eq!(category.group_id, group2.id);
319    }
320
321    #[test]
322    fn test_serialization() {
323        let group = CategoryGroup::new("Test Group");
324        let json = serde_json::to_string(&group).unwrap();
325        let deserialized: CategoryGroup = serde_json::from_str(&json).unwrap();
326        assert_eq!(group.id, deserialized.id);
327        assert_eq!(group.name, deserialized.name);
328
329        let category = Category::new("Test Category", group.id);
330        let json = serde_json::to_string(&category).unwrap();
331        let deserialized: Category = serde_json::from_str(&json).unwrap();
332        assert_eq!(category.id, deserialized.id);
333        assert_eq!(category.name, deserialized.name);
334    }
335}