envelope_cli/services/
payee.rs

1//! Payee service
2//!
3//! Provides business logic for payee management including auto-suggestion,
4//! category learning, and fuzzy matching.
5
6use crate::audit::EntityType;
7use crate::error::{EnvelopeError, EnvelopeResult};
8use crate::models::{CategoryId, Payee, PayeeId};
9use crate::storage::Storage;
10
11/// Service for payee management
12pub struct PayeeService<'a> {
13    storage: &'a Storage,
14}
15
16impl<'a> PayeeService<'a> {
17    /// Create a new payee service
18    pub fn new(storage: &'a Storage) -> Self {
19        Self { storage }
20    }
21
22    /// Create a new payee
23    pub fn create(&self, name: &str) -> EnvelopeResult<Payee> {
24        let name = name.trim();
25        if name.is_empty() {
26            return Err(EnvelopeError::Validation(
27                "Payee name cannot be empty".into(),
28            ));
29        }
30
31        // Check for duplicate
32        if self.storage.payees.get_by_name(name)?.is_some() {
33            return Err(EnvelopeError::Duplicate {
34                entity_type: "Payee",
35                identifier: name.to_string(),
36            });
37        }
38
39        let mut payee = Payee::new(name);
40        payee.manual = true;
41
42        // Validate
43        payee
44            .validate()
45            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
46
47        // Save
48        self.storage.payees.upsert(payee.clone())?;
49        self.storage.payees.save()?;
50
51        // Audit log
52        self.storage.log_create(
53            EntityType::Payee,
54            payee.id.to_string(),
55            Some(payee.name.clone()),
56            &payee,
57        )?;
58
59        Ok(payee)
60    }
61
62    /// Create a payee with a default category
63    pub fn create_with_category(
64        &self,
65        name: &str,
66        category_id: CategoryId,
67    ) -> EnvelopeResult<Payee> {
68        let name = name.trim();
69        if name.is_empty() {
70            return Err(EnvelopeError::Validation(
71                "Payee name cannot be empty".into(),
72            ));
73        }
74
75        // Verify category exists
76        self.storage
77            .categories
78            .get_category(category_id)?
79            .ok_or_else(|| EnvelopeError::category_not_found(category_id.to_string()))?;
80
81        // Check for duplicate
82        if self.storage.payees.get_by_name(name)?.is_some() {
83            return Err(EnvelopeError::Duplicate {
84                entity_type: "Payee",
85                identifier: name.to_string(),
86            });
87        }
88
89        let payee = Payee::with_default_category(name, category_id);
90
91        // Validate
92        payee
93            .validate()
94            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
95
96        // Save
97        self.storage.payees.upsert(payee.clone())?;
98        self.storage.payees.save()?;
99
100        // Audit log
101        self.storage.log_create(
102            EntityType::Payee,
103            payee.id.to_string(),
104            Some(payee.name.clone()),
105            &payee,
106        )?;
107
108        Ok(payee)
109    }
110
111    /// Get a payee by ID
112    pub fn get(&self, id: PayeeId) -> EnvelopeResult<Option<Payee>> {
113        self.storage.payees.get(id)
114    }
115
116    /// Get a payee by name (case-insensitive)
117    pub fn get_by_name(&self, name: &str) -> EnvelopeResult<Option<Payee>> {
118        self.storage.payees.get_by_name(name)
119    }
120
121    /// Find a payee by ID or name
122    pub fn find(&self, identifier: &str) -> EnvelopeResult<Option<Payee>> {
123        // Try by name first
124        if let Some(payee) = self.storage.payees.get_by_name(identifier)? {
125            return Ok(Some(payee));
126        }
127
128        // Try parsing as ID
129        if let Ok(id) = identifier.parse::<PayeeId>() {
130            return self.storage.payees.get(id);
131        }
132
133        Ok(None)
134    }
135
136    /// Get or create a payee by name
137    pub fn get_or_create(&self, name: &str) -> EnvelopeResult<Payee> {
138        self.storage.payees.get_or_create(name)
139    }
140
141    /// List all payees
142    pub fn list(&self) -> EnvelopeResult<Vec<Payee>> {
143        self.storage.payees.get_all()
144    }
145
146    /// Search payees by name (fuzzy match)
147    pub fn search(&self, query: &str, limit: usize) -> EnvelopeResult<Vec<Payee>> {
148        self.storage.payees.search(query, limit)
149    }
150
151    /// Suggest payees matching a partial name
152    pub fn suggest(&self, partial: &str) -> EnvelopeResult<Vec<Payee>> {
153        self.storage.payees.search(partial, 10)
154    }
155
156    /// Get the suggested category for a payee
157    pub fn get_suggested_category(&self, payee_name: &str) -> EnvelopeResult<Option<CategoryId>> {
158        if let Some(payee) = self.storage.payees.get_by_name(payee_name)? {
159            Ok(payee.suggested_category())
160        } else {
161            Ok(None)
162        }
163    }
164
165    /// Set the default category for a payee
166    pub fn set_default_category(
167        &self,
168        id: PayeeId,
169        category_id: CategoryId,
170    ) -> EnvelopeResult<Payee> {
171        let mut payee = self
172            .storage
173            .payees
174            .get(id)?
175            .ok_or_else(|| EnvelopeError::payee_not_found(id.to_string()))?;
176
177        // Verify category exists
178        self.storage
179            .categories
180            .get_category(category_id)?
181            .ok_or_else(|| EnvelopeError::category_not_found(category_id.to_string()))?;
182
183        let before = payee.clone();
184        payee.set_default_category(category_id);
185
186        // Save
187        self.storage.payees.upsert(payee.clone())?;
188        self.storage.payees.save()?;
189
190        // Audit log
191        self.storage.log_update(
192            EntityType::Payee,
193            payee.id.to_string(),
194            Some(payee.name.clone()),
195            &before,
196            &payee,
197            Some(format!(
198                "default_category: {:?} -> {:?}",
199                before.default_category_id, payee.default_category_id
200            )),
201        )?;
202
203        Ok(payee)
204    }
205
206    /// Clear the default category for a payee
207    pub fn clear_default_category(&self, id: PayeeId) -> EnvelopeResult<Payee> {
208        let mut payee = self
209            .storage
210            .payees
211            .get(id)?
212            .ok_or_else(|| EnvelopeError::payee_not_found(id.to_string()))?;
213
214        let before = payee.clone();
215        payee.clear_default_category();
216
217        // Save
218        self.storage.payees.upsert(payee.clone())?;
219        self.storage.payees.save()?;
220
221        // Audit log
222        self.storage.log_update(
223            EntityType::Payee,
224            payee.id.to_string(),
225            Some(payee.name.clone()),
226            &before,
227            &payee,
228            Some(format!(
229                "default_category: {:?} -> None",
230                before.default_category_id
231            )),
232        )?;
233
234        Ok(payee)
235    }
236
237    /// Record a category usage for a payee (for learning)
238    pub fn record_category_usage(
239        &self,
240        payee_id: PayeeId,
241        category_id: CategoryId,
242    ) -> EnvelopeResult<()> {
243        if let Some(mut payee) = self.storage.payees.get(payee_id)? {
244            payee.record_category_usage(category_id);
245            self.storage.payees.upsert(payee)?;
246            self.storage.payees.save()?;
247        }
248        Ok(())
249    }
250
251    /// Delete a payee
252    pub fn delete(&self, id: PayeeId) -> EnvelopeResult<Payee> {
253        let payee = self
254            .storage
255            .payees
256            .get(id)?
257            .ok_or_else(|| EnvelopeError::payee_not_found(id.to_string()))?;
258
259        self.storage.payees.delete(id)?;
260        self.storage.payees.save()?;
261
262        // Audit log
263        self.storage.log_delete(
264            EntityType::Payee,
265            id.to_string(),
266            Some(payee.name.clone()),
267            &payee,
268        )?;
269
270        Ok(payee)
271    }
272
273    /// Rename a payee
274    pub fn rename(&self, id: PayeeId, new_name: &str) -> EnvelopeResult<Payee> {
275        let new_name = new_name.trim();
276        if new_name.is_empty() {
277            return Err(EnvelopeError::Validation(
278                "Payee name cannot be empty".into(),
279            ));
280        }
281
282        let mut payee = self
283            .storage
284            .payees
285            .get(id)?
286            .ok_or_else(|| EnvelopeError::payee_not_found(id.to_string()))?;
287
288        // Check for duplicate (excluding self)
289        if let Some(existing) = self.storage.payees.get_by_name(new_name)? {
290            if existing.id != id {
291                return Err(EnvelopeError::Duplicate {
292                    entity_type: "Payee",
293                    identifier: new_name.to_string(),
294                });
295            }
296        }
297
298        let before = payee.clone();
299        payee.name = new_name.to_string();
300        payee.updated_at = chrono::Utc::now();
301
302        // Validate
303        payee
304            .validate()
305            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
306
307        // Save
308        self.storage.payees.upsert(payee.clone())?;
309        self.storage.payees.save()?;
310
311        // Audit log
312        self.storage.log_update(
313            EntityType::Payee,
314            payee.id.to_string(),
315            Some(payee.name.clone()),
316            &before,
317            &payee,
318            Some(format!("name: '{}' -> '{}'", before.name, payee.name)),
319        )?;
320
321        Ok(payee)
322    }
323
324    /// Count payees
325    pub fn count(&self) -> EnvelopeResult<usize> {
326        self.storage.payees.count()
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::config::paths::EnvelopePaths;
334    use crate::models::{Category, CategoryGroup};
335    use tempfile::TempDir;
336
337    fn create_test_storage() -> (TempDir, Storage) {
338        let temp_dir = TempDir::new().unwrap();
339        let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
340        let mut storage = Storage::new(paths).unwrap();
341        storage.load_all().unwrap();
342        (temp_dir, storage)
343    }
344
345    fn setup_test_category(storage: &Storage) -> CategoryId {
346        let group = CategoryGroup::new("Test Group");
347        storage.categories.upsert_group(group.clone()).unwrap();
348
349        let category = Category::new("Groceries", group.id);
350        let category_id = category.id;
351        storage.categories.upsert_category(category).unwrap();
352        storage.categories.save().unwrap();
353
354        category_id
355    }
356
357    #[test]
358    fn test_create_payee() {
359        let (_temp_dir, storage) = create_test_storage();
360        let service = PayeeService::new(&storage);
361
362        let payee = service.create("Test Store").unwrap();
363        assert_eq!(payee.name, "Test Store");
364        assert!(payee.manual);
365    }
366
367    #[test]
368    fn test_create_with_category() {
369        let (_temp_dir, storage) = create_test_storage();
370        let category_id = setup_test_category(&storage);
371        let service = PayeeService::new(&storage);
372
373        let payee = service
374            .create_with_category("Grocery Store", category_id)
375            .unwrap();
376
377        assert_eq!(payee.default_category_id, Some(category_id));
378        assert!(payee.manual);
379    }
380
381    #[test]
382    fn test_duplicate_payee() {
383        let (_temp_dir, storage) = create_test_storage();
384        let service = PayeeService::new(&storage);
385
386        service.create("Test Store").unwrap();
387        let result = service.create("test store"); // case insensitive
388        assert!(matches!(result, Err(EnvelopeError::Duplicate { .. })));
389    }
390
391    #[test]
392    fn test_search_payees() {
393        let (_temp_dir, storage) = create_test_storage();
394        let service = PayeeService::new(&storage);
395
396        service.create("Grocery Store").unwrap();
397        service.create("Gas Station").unwrap();
398        service.create("Restaurant").unwrap();
399
400        let results = service.search("groc", 10).unwrap();
401        assert!(!results.is_empty());
402        assert_eq!(results[0].name, "Grocery Store");
403    }
404
405    #[test]
406    fn test_category_learning() {
407        let (_temp_dir, storage) = create_test_storage();
408        let category_id = setup_test_category(&storage);
409        let service = PayeeService::new(&storage);
410
411        let payee = service.create("Learning Store").unwrap();
412
413        // Record some usage
414        service
415            .record_category_usage(payee.id, category_id)
416            .unwrap();
417        service
418            .record_category_usage(payee.id, category_id)
419            .unwrap();
420
421        // Check suggested category
422        let suggested = service.get_suggested_category("Learning Store").unwrap();
423        assert_eq!(suggested, Some(category_id));
424    }
425
426    #[test]
427    fn test_set_default_category() {
428        let (_temp_dir, storage) = create_test_storage();
429        let category_id = setup_test_category(&storage);
430        let service = PayeeService::new(&storage);
431
432        let payee = service.create("Test Payee").unwrap();
433        assert!(payee.default_category_id.is_none());
434
435        let updated = service.set_default_category(payee.id, category_id).unwrap();
436        assert_eq!(updated.default_category_id, Some(category_id));
437        assert!(updated.manual);
438    }
439
440    #[test]
441    fn test_delete_payee() {
442        let (_temp_dir, storage) = create_test_storage();
443        let service = PayeeService::new(&storage);
444
445        let payee = service.create("To Delete").unwrap();
446        assert_eq!(service.count().unwrap(), 1);
447
448        service.delete(payee.id).unwrap();
449        assert_eq!(service.count().unwrap(), 0);
450    }
451
452    #[test]
453    fn test_rename_payee() {
454        let (_temp_dir, storage) = create_test_storage();
455        let service = PayeeService::new(&storage);
456
457        let payee = service.create("Old Name").unwrap();
458        let renamed = service.rename(payee.id, "New Name").unwrap();
459
460        assert_eq!(renamed.name, "New Name");
461        assert!(service.get_by_name("Old Name").unwrap().is_none());
462        assert!(service.get_by_name("New Name").unwrap().is_some());
463    }
464}