1use crate::audit::EntityType;
7use crate::error::{EnvelopeError, EnvelopeResult};
8use crate::models::{Category, CategoryGroup, CategoryGroupId, CategoryId};
9use crate::storage::Storage;
10
11pub struct CategoryService<'a> {
13 storage: &'a Storage,
14}
15
16#[derive(Debug, Clone)]
18pub struct CategoryGroupWithCategories {
19 pub group: CategoryGroup,
20 pub categories: Vec<Category>,
21}
22
23impl<'a> CategoryService<'a> {
24 pub fn new(storage: &'a Storage) -> Self {
26 Self { storage }
27 }
28
29 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 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 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 self.storage.categories.upsert_group(group.clone())?;
61 self.storage.categories.save()?;
62
63 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 pub fn get_group(&self, id: CategoryGroupId) -> EnvelopeResult<Option<CategoryGroup>> {
76 self.storage.categories.get_group(id)
77 }
78
79 pub fn get_group_by_name(&self, name: &str) -> EnvelopeResult<Option<CategoryGroup>> {
81 self.storage.categories.get_group_by_name(name)
82 }
83
84 pub fn find_group(&self, identifier: &str) -> EnvelopeResult<Option<CategoryGroup>> {
86 if let Some(group) = self.storage.categories.get_group_by_name(identifier)? {
88 return Ok(Some(group));
89 }
90
91 if let Ok(id) = identifier.parse::<CategoryGroupId>() {
93 return self.storage.categories.get_group(id);
94 }
95
96 Ok(None)
97 }
98
99 pub fn list_groups(&self) -> EnvelopeResult<Vec<CategoryGroup>> {
101 self.storage.categories.get_all_groups()
102 }
103
104 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn get_category(&self, id: CategoryId) -> EnvelopeResult<Option<Category>> {
303 self.storage.categories.get_category(id)
304 }
305
306 pub fn get_category_by_name(&self, name: &str) -> EnvelopeResult<Option<Category>> {
308 self.storage.categories.get_category_by_name(name)
309 }
310
311 pub fn find_category(&self, identifier: &str) -> EnvelopeResult<Option<Category>> {
313 if let Some(category) = self.storage.categories.get_category_by_name(identifier)? {
315 return Ok(Some(category));
316 }
317
318 if let Ok(id) = identifier.parse::<CategoryId>() {
320 return self.storage.categories.get_category(id);
321 }
322
323 Ok(None)
324 }
325
326 pub fn list_categories(&self) -> EnvelopeResult<Vec<Category>> {
328 self.storage.categories.get_all_categories()
329 }
330
331 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 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 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 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 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 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 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 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 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 self.storage.backup_before_destructive()?;
489
490 self.storage.categories.delete_category(id)?;
491 self.storage.categories.save()?;
492
493 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 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 let found = service.find_category("monthly rent").unwrap().unwrap();
624 assert_eq!(found.id, category.id);
625 }
626}