envelope_cli/services/
category.rs

1//! Category service
2//!
3//! Provides business logic for category and category group management
4//! including CRUD operations, reordering, and moving categories between groups.
5
6use crate::audit::EntityType;
7use crate::error::{EnvelopeError, EnvelopeResult};
8use crate::models::{Category, CategoryGroup, CategoryGroupId, CategoryId};
9use crate::storage::Storage;
10
11/// Service for category management
12pub struct CategoryService<'a> {
13    storage: &'a Storage,
14}
15
16/// A category group with its categories
17#[derive(Debug, Clone)]
18pub struct CategoryGroupWithCategories {
19    pub group: CategoryGroup,
20    pub categories: Vec<Category>,
21}
22
23impl<'a> CategoryService<'a> {
24    /// Create a new category service
25    pub fn new(storage: &'a Storage) -> Self {
26        Self { storage }
27    }
28
29    // === Group Operations ===
30
31    /// Create a new category group
32    pub fn create_group(&self, name: &str) -> EnvelopeResult<CategoryGroup> {
33        let name = name.trim();
34        if name.is_empty() {
35            return Err(EnvelopeError::Validation(
36                "Category group name cannot be empty".into(),
37            ));
38        }
39
40        // Check for duplicate name
41        if self.storage.categories.get_group_by_name(name)?.is_some() {
42            return Err(EnvelopeError::Duplicate {
43                entity_type: "Category Group",
44                identifier: name.to_string(),
45            });
46        }
47
48        // Get max sort order
49        let groups = self.storage.categories.get_all_groups()?;
50        let max_order = groups.iter().map(|g| g.sort_order).max().unwrap_or(-1);
51
52        let mut group = CategoryGroup::new(name);
53        group.sort_order = max_order + 1;
54
55        group
56            .validate()
57            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
58
59        // Save
60        self.storage.categories.upsert_group(group.clone())?;
61        self.storage.categories.save()?;
62
63        // Audit log
64        self.storage.log_create(
65            EntityType::CategoryGroup,
66            group.id.to_string(),
67            Some(group.name.clone()),
68            &group,
69        )?;
70
71        Ok(group)
72    }
73
74    /// Get a group by ID
75    pub fn get_group(&self, id: CategoryGroupId) -> EnvelopeResult<Option<CategoryGroup>> {
76        self.storage.categories.get_group(id)
77    }
78
79    /// Get a group by name (case-insensitive)
80    pub fn get_group_by_name(&self, name: &str) -> EnvelopeResult<Option<CategoryGroup>> {
81        self.storage.categories.get_group_by_name(name)
82    }
83
84    /// Find a group by name or ID string
85    pub fn find_group(&self, identifier: &str) -> EnvelopeResult<Option<CategoryGroup>> {
86        // Try by name first
87        if let Some(group) = self.storage.categories.get_group_by_name(identifier)? {
88            return Ok(Some(group));
89        }
90
91        // Try parsing as ID
92        if let Ok(id) = identifier.parse::<CategoryGroupId>() {
93            return self.storage.categories.get_group(id);
94        }
95
96        Ok(None)
97    }
98
99    /// List all groups
100    pub fn list_groups(&self) -> EnvelopeResult<Vec<CategoryGroup>> {
101        self.storage.categories.get_all_groups()
102    }
103
104    /// List all groups with their categories
105    pub fn list_groups_with_categories(&self) -> EnvelopeResult<Vec<CategoryGroupWithCategories>> {
106        let groups = self.storage.categories.get_all_groups()?;
107        let mut result = Vec::with_capacity(groups.len());
108
109        for group in groups {
110            let categories = self.storage.categories.get_categories_in_group(group.id)?;
111            result.push(CategoryGroupWithCategories { group, categories });
112        }
113
114        Ok(result)
115    }
116
117    /// Update a group's name
118    pub fn update_group(
119        &self,
120        id: CategoryGroupId,
121        name: Option<&str>,
122    ) -> EnvelopeResult<CategoryGroup> {
123        let mut group =
124            self.storage
125                .categories
126                .get_group(id)?
127                .ok_or_else(|| EnvelopeError::NotFound {
128                    entity_type: "Category Group",
129                    identifier: id.to_string(),
130                })?;
131
132        let before = group.clone();
133
134        if let Some(new_name) = name {
135            let new_name = new_name.trim();
136            if new_name.is_empty() {
137                return Err(EnvelopeError::Validation(
138                    "Category group name cannot be empty".into(),
139                ));
140            }
141
142            // Check for duplicate
143            if let Some(existing) = self.storage.categories.get_group_by_name(new_name)? {
144                if existing.id != id {
145                    return Err(EnvelopeError::Duplicate {
146                        entity_type: "Category Group",
147                        identifier: new_name.to_string(),
148                    });
149                }
150            }
151
152            group.name = new_name.to_string();
153        }
154
155        group.updated_at = chrono::Utc::now();
156        group
157            .validate()
158            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
159
160        self.storage.categories.upsert_group(group.clone())?;
161        self.storage.categories.save()?;
162
163        // Audit
164        if before.name != group.name {
165            self.storage.log_update(
166                EntityType::CategoryGroup,
167                group.id.to_string(),
168                Some(group.name.clone()),
169                &before,
170                &group,
171                Some(format!("name: {} -> {}", before.name, group.name)),
172            )?;
173        }
174
175        Ok(group)
176    }
177
178    /// Delete a group
179    ///
180    /// If the group has categories, they must be moved or deleted first
181    /// unless force_delete_categories is true.
182    ///
183    /// Automatically creates a backup before deletion if one hasn't been
184    /// created recently.
185    pub fn delete_group(
186        &self,
187        id: CategoryGroupId,
188        force_delete_categories: bool,
189    ) -> EnvelopeResult<()> {
190        let group =
191            self.storage
192                .categories
193                .get_group(id)?
194                .ok_or_else(|| EnvelopeError::NotFound {
195                    entity_type: "Category Group",
196                    identifier: id.to_string(),
197                })?;
198
199        let categories = self.storage.categories.get_categories_in_group(id)?;
200        if !categories.is_empty() && !force_delete_categories {
201            return Err(EnvelopeError::Validation(format!(
202                "Cannot delete group '{}' - it contains {} categories. Use --force to delete them.",
203                group.name,
204                categories.len()
205            )));
206        }
207
208        // Create automatic backup before destructive operation
209        self.storage.backup_before_destructive()?;
210
211        self.storage
212            .categories
213            .delete_group(id, force_delete_categories)?;
214        self.storage.categories.save()?;
215
216        // Audit
217        self.storage.log_delete(
218            EntityType::CategoryGroup,
219            group.id.to_string(),
220            Some(group.name.clone()),
221            &group,
222        )?;
223
224        Ok(())
225    }
226
227    /// Reorder groups
228    pub fn reorder_groups(&self, order: &[CategoryGroupId]) -> EnvelopeResult<()> {
229        for (i, &id) in order.iter().enumerate() {
230            if let Some(mut group) = self.storage.categories.get_group(id)? {
231                group.sort_order = i as i32;
232                group.updated_at = chrono::Utc::now();
233                self.storage.categories.upsert_group(group)?;
234            }
235        }
236        self.storage.categories.save()?;
237        Ok(())
238    }
239
240    // === Category Operations ===
241
242    /// Create a new category in a group
243    pub fn create_category(
244        &self,
245        name: &str,
246        group_id: CategoryGroupId,
247    ) -> EnvelopeResult<Category> {
248        let name = name.trim();
249        if name.is_empty() {
250            return Err(EnvelopeError::Validation(
251                "Category name cannot be empty".into(),
252            ));
253        }
254
255        // Verify group exists
256        if self.storage.categories.get_group(group_id)?.is_none() {
257            return Err(EnvelopeError::NotFound {
258                entity_type: "Category Group",
259                identifier: group_id.to_string(),
260            });
261        }
262
263        // Check for duplicate name (globally)
264        if self
265            .storage
266            .categories
267            .get_category_by_name(name)?
268            .is_some()
269        {
270            return Err(EnvelopeError::Duplicate {
271                entity_type: "Category",
272                identifier: name.to_string(),
273            });
274        }
275
276        // Get max sort order in group
277        let categories = self.storage.categories.get_categories_in_group(group_id)?;
278        let max_order = categories.iter().map(|c| c.sort_order).max().unwrap_or(-1);
279
280        let mut category = Category::new(name, group_id);
281        category.sort_order = max_order + 1;
282
283        category
284            .validate()
285            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
286
287        self.storage.categories.upsert_category(category.clone())?;
288        self.storage.categories.save()?;
289
290        // Audit
291        self.storage.log_create(
292            EntityType::Category,
293            category.id.to_string(),
294            Some(category.name.clone()),
295            &category,
296        )?;
297
298        Ok(category)
299    }
300
301    /// Get a category by ID
302    pub fn get_category(&self, id: CategoryId) -> EnvelopeResult<Option<Category>> {
303        self.storage.categories.get_category(id)
304    }
305
306    /// Get a category by name (case-insensitive)
307    pub fn get_category_by_name(&self, name: &str) -> EnvelopeResult<Option<Category>> {
308        self.storage.categories.get_category_by_name(name)
309    }
310
311    /// Find a category by name or ID string
312    pub fn find_category(&self, identifier: &str) -> EnvelopeResult<Option<Category>> {
313        // Try by name first
314        if let Some(category) = self.storage.categories.get_category_by_name(identifier)? {
315            return Ok(Some(category));
316        }
317
318        // Try parsing as ID
319        if let Ok(id) = identifier.parse::<CategoryId>() {
320            return self.storage.categories.get_category(id);
321        }
322
323        Ok(None)
324    }
325
326    /// List all categories
327    pub fn list_categories(&self) -> EnvelopeResult<Vec<Category>> {
328        self.storage.categories.get_all_categories()
329    }
330
331    /// List categories in a group
332    pub fn list_categories_in_group(
333        &self,
334        group_id: CategoryGroupId,
335    ) -> EnvelopeResult<Vec<Category>> {
336        self.storage.categories.get_categories_in_group(group_id)
337    }
338
339    /// Update a category
340    pub fn update_category(
341        &self,
342        id: CategoryId,
343        name: Option<&str>,
344        goal: Option<i64>,
345        clear_goal: bool,
346    ) -> EnvelopeResult<Category> {
347        let mut category = self
348            .storage
349            .categories
350            .get_category(id)?
351            .ok_or_else(|| EnvelopeError::category_not_found(id.to_string()))?;
352
353        let before = category.clone();
354
355        if let Some(new_name) = name {
356            let new_name = new_name.trim();
357            if new_name.is_empty() {
358                return Err(EnvelopeError::Validation(
359                    "Category name cannot be empty".into(),
360                ));
361            }
362
363            // Check for duplicate
364            if let Some(existing) = self.storage.categories.get_category_by_name(new_name)? {
365                if existing.id != id {
366                    return Err(EnvelopeError::Duplicate {
367                        entity_type: "Category",
368                        identifier: new_name.to_string(),
369                    });
370                }
371            }
372
373            category.name = new_name.to_string();
374        }
375
376        if clear_goal {
377            category.clear_goal();
378        } else if let Some(goal_amount) = goal {
379            category.set_goal(goal_amount);
380        }
381
382        category.updated_at = chrono::Utc::now();
383        category
384            .validate()
385            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
386
387        self.storage.categories.upsert_category(category.clone())?;
388        self.storage.categories.save()?;
389
390        // Audit
391        let mut changes = Vec::new();
392        if before.name != category.name {
393            changes.push(format!("name: {} -> {}", before.name, category.name));
394        }
395        if before.goal_amount != category.goal_amount {
396            changes.push(format!(
397                "goal: {:?} -> {:?}",
398                before.goal_amount, category.goal_amount
399            ));
400        }
401
402        if !changes.is_empty() {
403            self.storage.log_update(
404                EntityType::Category,
405                category.id.to_string(),
406                Some(category.name.clone()),
407                &before,
408                &category,
409                Some(changes.join(", ")),
410            )?;
411        }
412
413        Ok(category)
414    }
415
416    /// Move a category to a different group
417    pub fn move_category(
418        &self,
419        id: CategoryId,
420        new_group_id: CategoryGroupId,
421    ) -> EnvelopeResult<Category> {
422        let mut category = self
423            .storage
424            .categories
425            .get_category(id)?
426            .ok_or_else(|| EnvelopeError::category_not_found(id.to_string()))?;
427
428        // Verify new group exists
429        let new_group = self
430            .storage
431            .categories
432            .get_group(new_group_id)?
433            .ok_or_else(|| EnvelopeError::NotFound {
434                entity_type: "Category Group",
435                identifier: new_group_id.to_string(),
436            })?;
437
438        let before = category.clone();
439        let old_group = self.storage.categories.get_group(category.group_id)?;
440
441        category.move_to_group(new_group_id);
442
443        // Update sort order to be last in new group
444        let categories = self
445            .storage
446            .categories
447            .get_categories_in_group(new_group_id)?;
448        let max_order = categories.iter().map(|c| c.sort_order).max().unwrap_or(-1);
449        category.sort_order = max_order + 1;
450
451        self.storage.categories.upsert_category(category.clone())?;
452        self.storage.categories.save()?;
453
454        // Audit
455        self.storage.log_update(
456            EntityType::Category,
457            category.id.to_string(),
458            Some(category.name.clone()),
459            &before,
460            &category,
461            Some(format!(
462                "moved from '{}' to '{}'",
463                old_group
464                    .map(|g| g.name)
465                    .unwrap_or_else(|| "Unknown".into()),
466                new_group.name
467            )),
468        )?;
469
470        Ok(category)
471    }
472
473    /// Delete a category
474    ///
475    /// Automatically creates a backup before deletion if one hasn't been
476    /// created recently.
477    pub fn delete_category(&self, id: CategoryId) -> EnvelopeResult<()> {
478        let category = self
479            .storage
480            .categories
481            .get_category(id)?
482            .ok_or_else(|| EnvelopeError::category_not_found(id.to_string()))?;
483
484        // TODO: Check for budget allocations and transactions using this category
485        // For now, just delete
486
487        // Create automatic backup before destructive operation
488        self.storage.backup_before_destructive()?;
489
490        self.storage.categories.delete_category(id)?;
491        self.storage.categories.save()?;
492
493        // Audit
494        self.storage.log_delete(
495            EntityType::Category,
496            category.id.to_string(),
497            Some(category.name.clone()),
498            &category,
499        )?;
500
501        Ok(())
502    }
503
504    /// Reorder categories within a group
505    pub fn reorder_categories(
506        &self,
507        group_id: CategoryGroupId,
508        order: &[CategoryId],
509    ) -> EnvelopeResult<()> {
510        for (i, &id) in order.iter().enumerate() {
511            if let Some(mut category) = self.storage.categories.get_category(id)? {
512                if category.group_id == group_id {
513                    category.sort_order = i as i32;
514                    category.updated_at = chrono::Utc::now();
515                    self.storage.categories.upsert_category(category)?;
516                }
517            }
518        }
519        self.storage.categories.save()?;
520        Ok(())
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use crate::config::paths::EnvelopePaths;
528    use tempfile::TempDir;
529
530    fn create_test_storage() -> (TempDir, Storage) {
531        let temp_dir = TempDir::new().unwrap();
532        let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
533        let mut storage = Storage::new(paths).unwrap();
534        storage.load_all().unwrap();
535        (temp_dir, storage)
536    }
537
538    #[test]
539    fn test_create_group() {
540        let (_temp_dir, storage) = create_test_storage();
541        let service = CategoryService::new(&storage);
542
543        let group = service.create_group("Bills").unwrap();
544        assert_eq!(group.name, "Bills");
545        assert_eq!(group.sort_order, 0);
546    }
547
548    #[test]
549    fn test_create_duplicate_group() {
550        let (_temp_dir, storage) = create_test_storage();
551        let service = CategoryService::new(&storage);
552
553        service.create_group("Bills").unwrap();
554        let result = service.create_group("Bills");
555        assert!(matches!(result, Err(EnvelopeError::Duplicate { .. })));
556    }
557
558    #[test]
559    fn test_create_category() {
560        let (_temp_dir, storage) = create_test_storage();
561        let service = CategoryService::new(&storage);
562
563        let group = service.create_group("Bills").unwrap();
564        let category = service.create_category("Rent", group.id).unwrap();
565
566        assert_eq!(category.name, "Rent");
567        assert_eq!(category.group_id, group.id);
568    }
569
570    #[test]
571    fn test_list_groups_with_categories() {
572        let (_temp_dir, storage) = create_test_storage();
573        let service = CategoryService::new(&storage);
574
575        let group = service.create_group("Bills").unwrap();
576        service.create_category("Rent", group.id).unwrap();
577        service.create_category("Electric", group.id).unwrap();
578
579        let result = service.list_groups_with_categories().unwrap();
580        assert_eq!(result.len(), 1);
581        assert_eq!(result[0].categories.len(), 2);
582    }
583
584    #[test]
585    fn test_move_category() {
586        let (_temp_dir, storage) = create_test_storage();
587        let service = CategoryService::new(&storage);
588
589        let bills = service.create_group("Bills").unwrap();
590        let needs = service.create_group("Needs").unwrap();
591
592        let category = service.create_category("Groceries", bills.id).unwrap();
593        assert_eq!(category.group_id, bills.id);
594
595        let moved = service.move_category(category.id, needs.id).unwrap();
596        assert_eq!(moved.group_id, needs.id);
597    }
598
599    #[test]
600    fn test_delete_category() {
601        let (_temp_dir, storage) = create_test_storage();
602        let service = CategoryService::new(&storage);
603
604        let group = service.create_group("Bills").unwrap();
605        let category = service.create_category("Rent", group.id).unwrap();
606
607        assert!(service.get_category(category.id).unwrap().is_some());
608
609        service.delete_category(category.id).unwrap();
610
611        assert!(service.get_category(category.id).unwrap().is_none());
612    }
613
614    #[test]
615    fn test_find_category() {
616        let (_temp_dir, storage) = create_test_storage();
617        let service = CategoryService::new(&storage);
618
619        let group = service.create_group("Bills").unwrap();
620        let category = service.create_category("Monthly Rent", group.id).unwrap();
621
622        // Find by name (case insensitive)
623        let found = service.find_category("monthly rent").unwrap().unwrap();
624        assert_eq!(found.id, category.id);
625    }
626}