envelope_cli/models/
payee.rs

1//! Payee model
2//!
3//! Tracks payees and their auto-categorization rules based on historical patterns.
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9
10use super::ids::{CategoryId, PayeeId};
11
12/// A payee with auto-categorization rules
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Payee {
15    /// Unique identifier
16    pub id: PayeeId,
17
18    /// Payee name
19    pub name: String,
20
21    /// Default category for new transactions with this payee
22    pub default_category_id: Option<CategoryId>,
23
24    /// Category usage frequency for learning (category_id -> count)
25    #[serde(default)]
26    pub category_frequency: HashMap<CategoryId, u32>,
27
28    /// Whether this payee was manually created vs auto-created from transaction
29    #[serde(default)]
30    pub manual: bool,
31
32    /// When the payee was created
33    pub created_at: DateTime<Utc>,
34
35    /// When the payee was last modified
36    pub updated_at: DateTime<Utc>,
37}
38
39impl Payee {
40    /// Create a new payee
41    pub fn new(name: impl Into<String>) -> Self {
42        let now = Utc::now();
43        Self {
44            id: PayeeId::new(),
45            name: name.into(),
46            default_category_id: None,
47            category_frequency: HashMap::new(),
48            manual: false,
49            created_at: now,
50            updated_at: now,
51        }
52    }
53
54    /// Create a manually-created payee with a default category
55    pub fn with_default_category(name: impl Into<String>, category_id: CategoryId) -> Self {
56        let mut payee = Self::new(name);
57        payee.default_category_id = Some(category_id);
58        payee.manual = true;
59        payee
60    }
61
62    /// Record a category usage for learning
63    pub fn record_category_usage(&mut self, category_id: CategoryId) {
64        *self.category_frequency.entry(category_id).or_insert(0) += 1;
65        self.updated_at = Utc::now();
66
67        // Auto-update default category if not manually set
68        if !self.manual {
69            self.update_default_from_frequency();
70        }
71    }
72
73    /// Update the default category based on frequency
74    fn update_default_from_frequency(&mut self) {
75        if let Some((&most_used_category, _)) = self
76            .category_frequency
77            .iter()
78            .max_by_key(|(_, count)| *count)
79        {
80            self.default_category_id = Some(most_used_category);
81        }
82    }
83
84    /// Get the suggested category (default or most frequent)
85    pub fn suggested_category(&self) -> Option<CategoryId> {
86        self.default_category_id.or_else(|| {
87            self.category_frequency
88                .iter()
89                .max_by_key(|(_, count)| *count)
90                .map(|(&category_id, _)| category_id)
91        })
92    }
93
94    /// Set the default category manually
95    pub fn set_default_category(&mut self, category_id: CategoryId) {
96        self.default_category_id = Some(category_id);
97        self.manual = true;
98        self.updated_at = Utc::now();
99    }
100
101    /// Clear the default category
102    pub fn clear_default_category(&mut self) {
103        self.default_category_id = None;
104        self.manual = false;
105        self.updated_at = Utc::now();
106    }
107
108    /// Validate the payee
109    pub fn validate(&self) -> Result<(), PayeeValidationError> {
110        if self.name.trim().is_empty() {
111            return Err(PayeeValidationError::EmptyName);
112        }
113
114        if self.name.len() > 100 {
115            return Err(PayeeValidationError::NameTooLong(self.name.len()));
116        }
117
118        Ok(())
119    }
120
121    /// Normalize a payee name for matching
122    pub fn normalize_name(name: &str) -> String {
123        name.trim().to_lowercase()
124    }
125
126    /// Check if this payee matches a name (case-insensitive)
127    pub fn matches_name(&self, name: &str) -> bool {
128        Self::normalize_name(&self.name) == Self::normalize_name(name)
129    }
130
131    /// Calculate similarity score for fuzzy matching (0.0 to 1.0)
132    pub fn similarity_score(&self, query: &str) -> f64 {
133        let name = Self::normalize_name(&self.name);
134        let query = Self::normalize_name(query);
135
136        if name == query {
137            return 1.0;
138        }
139
140        if name.contains(&query) || query.contains(&name) {
141            return 0.8;
142        }
143
144        // Simple character overlap similarity
145        let name_chars: std::collections::HashSet<char> = name.chars().collect();
146        let query_chars: std::collections::HashSet<char> = query.chars().collect();
147        let intersection = name_chars.intersection(&query_chars).count();
148        let union = name_chars.union(&query_chars).count();
149
150        if union == 0 {
151            0.0
152        } else {
153            intersection as f64 / union as f64
154        }
155    }
156}
157
158impl fmt::Display for Payee {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        write!(f, "{}", self.name)
161    }
162}
163
164/// Validation errors for payees
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum PayeeValidationError {
167    EmptyName,
168    NameTooLong(usize),
169}
170
171impl fmt::Display for PayeeValidationError {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        match self {
174            Self::EmptyName => write!(f, "Payee name cannot be empty"),
175            Self::NameTooLong(len) => {
176                write!(f, "Payee name too long ({} chars, max 100)", len)
177            }
178        }
179    }
180}
181
182impl std::error::Error for PayeeValidationError {}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn test_category_id() -> CategoryId {
189        CategoryId::new()
190    }
191
192    #[test]
193    fn test_new_payee() {
194        let payee = Payee::new("Test Store");
195        assert_eq!(payee.name, "Test Store");
196        assert!(payee.default_category_id.is_none());
197        assert!(!payee.manual);
198    }
199
200    #[test]
201    fn test_with_default_category() {
202        let category_id = test_category_id();
203        let payee = Payee::with_default_category("Test Store", category_id);
204
205        assert_eq!(payee.default_category_id, Some(category_id));
206        assert!(payee.manual);
207    }
208
209    #[test]
210    fn test_category_learning() {
211        let mut payee = Payee::new("Grocery Store");
212        let groceries = test_category_id();
213        let household = test_category_id();
214
215        // Record some usage
216        payee.record_category_usage(groceries);
217        payee.record_category_usage(groceries);
218        payee.record_category_usage(household);
219
220        assert_eq!(payee.category_frequency.get(&groceries), Some(&2));
221        assert_eq!(payee.category_frequency.get(&household), Some(&1));
222
223        // Groceries should be the suggested category
224        assert_eq!(payee.suggested_category(), Some(groceries));
225    }
226
227    #[test]
228    fn test_manual_override() {
229        let mut payee = Payee::new("Store");
230        let learned_category = test_category_id();
231        let manual_category = test_category_id();
232
233        // Learn a category
234        payee.record_category_usage(learned_category);
235        payee.record_category_usage(learned_category);
236        assert_eq!(payee.suggested_category(), Some(learned_category));
237
238        // Manual override
239        payee.set_default_category(manual_category);
240        assert_eq!(payee.suggested_category(), Some(manual_category));
241        assert!(payee.manual);
242
243        // Further learning should not change the manual default
244        payee.record_category_usage(learned_category);
245        assert_eq!(payee.suggested_category(), Some(manual_category));
246    }
247
248    #[test]
249    fn test_name_matching() {
250        let payee = Payee::new("Test Store");
251        assert!(payee.matches_name("Test Store"));
252        assert!(payee.matches_name("TEST STORE"));
253        assert!(payee.matches_name("test store"));
254        assert!(!payee.matches_name("Other Store"));
255    }
256
257    #[test]
258    fn test_similarity_score() {
259        let payee = Payee::new("Grocery Store");
260
261        assert_eq!(payee.similarity_score("Grocery Store"), 1.0);
262        assert_eq!(payee.similarity_score("grocery store"), 1.0);
263        assert!(payee.similarity_score("Grocery") >= 0.8);
264        assert!(payee.similarity_score("Store") >= 0.8);
265        assert!(payee.similarity_score("XYZ") < 0.5);
266    }
267
268    #[test]
269    fn test_validation() {
270        let mut payee = Payee::new("Valid Name");
271        assert!(payee.validate().is_ok());
272
273        payee.name = String::new();
274        assert_eq!(payee.validate(), Err(PayeeValidationError::EmptyName));
275
276        payee.name = "a".repeat(101);
277        assert!(matches!(
278            payee.validate(),
279            Err(PayeeValidationError::NameTooLong(_))
280        ));
281    }
282
283    #[test]
284    fn test_serialization() {
285        let mut payee = Payee::new("Test Store");
286        let category = test_category_id();
287        payee.record_category_usage(category);
288
289        let json = serde_json::to_string(&payee).unwrap();
290        let deserialized: Payee = serde_json::from_str(&json).unwrap();
291
292        assert_eq!(payee.id, deserialized.id);
293        assert_eq!(payee.name, deserialized.name);
294        assert_eq!(
295            payee.category_frequency.get(&category),
296            deserialized.category_frequency.get(&category)
297        );
298    }
299}