envelope_cli/models/
payee.rs1use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9
10use super::ids::{CategoryId, PayeeId};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Payee {
15 pub id: PayeeId,
17
18 pub name: String,
20
21 pub default_category_id: Option<CategoryId>,
23
24 #[serde(default)]
26 pub category_frequency: HashMap<CategoryId, u32>,
27
28 #[serde(default)]
30 pub manual: bool,
31
32 pub created_at: DateTime<Utc>,
34
35 pub updated_at: DateTime<Utc>,
37}
38
39impl Payee {
40 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 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 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 if !self.manual {
69 self.update_default_from_frequency();
70 }
71 }
72
73 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 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 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 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 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 pub fn normalize_name(name: &str) -> String {
123 name.trim().to_lowercase()
124 }
125
126 pub fn matches_name(&self, name: &str) -> bool {
128 Self::normalize_name(&self.name) == Self::normalize_name(name)
129 }
130
131 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 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#[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 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 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 payee.record_category_usage(learned_category);
235 payee.record_category_usage(learned_category);
236 assert_eq!(payee.suggested_category(), Some(learned_category));
237
238 payee.set_default_category(manual_category);
240 assert_eq!(payee.suggested_category(), Some(manual_category));
241 assert!(payee.manual);
242
243 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}