1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10use super::ids::{CategoryGroupId, CategoryId};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CategoryGroup {
15 pub id: CategoryGroupId,
17
18 pub name: String,
20
21 pub sort_order: i32,
23
24 #[serde(default)]
26 pub hidden: bool,
27
28 pub created_at: DateTime<Utc>,
30
31 pub updated_at: DateTime<Utc>,
33}
34
35impl CategoryGroup {
36 pub fn new(name: impl Into<String>) -> Self {
38 let now = Utc::now();
39 Self {
40 id: CategoryGroupId::new(),
41 name: name.into(),
42 sort_order: 0,
43 hidden: false,
44 created_at: now,
45 updated_at: now,
46 }
47 }
48
49 pub fn with_sort_order(name: impl Into<String>, sort_order: i32) -> Self {
51 let mut group = Self::new(name);
52 group.sort_order = sort_order;
53 group
54 }
55
56 pub fn validate(&self) -> Result<(), CategoryValidationError> {
58 if self.name.trim().is_empty() {
59 return Err(CategoryValidationError::EmptyName);
60 }
61
62 if self.name.len() > 50 {
63 return Err(CategoryValidationError::NameTooLong(self.name.len()));
64 }
65
66 Ok(())
67 }
68}
69
70impl fmt::Display for CategoryGroup {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 write!(f, "{}", self.name)
73 }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct Category {
79 pub id: CategoryId,
81
82 pub name: String,
84
85 pub group_id: CategoryGroupId,
87
88 pub sort_order: i32,
90
91 #[serde(default)]
93 pub hidden: bool,
94
95 pub goal_amount: Option<i64>,
97
98 #[serde(default)]
100 pub notes: String,
101
102 pub created_at: DateTime<Utc>,
104
105 pub updated_at: DateTime<Utc>,
107}
108
109impl Category {
110 pub fn new(name: impl Into<String>, group_id: CategoryGroupId) -> Self {
112 let now = Utc::now();
113 Self {
114 id: CategoryId::new(),
115 name: name.into(),
116 group_id,
117 sort_order: 0,
118 hidden: false,
119 goal_amount: None,
120 notes: String::new(),
121 created_at: now,
122 updated_at: now,
123 }
124 }
125
126 pub fn with_sort_order(
128 name: impl Into<String>,
129 group_id: CategoryGroupId,
130 sort_order: i32,
131 ) -> Self {
132 let mut category = Self::new(name, group_id);
133 category.sort_order = sort_order;
134 category
135 }
136
137 pub fn set_goal(&mut self, amount: i64) {
139 self.goal_amount = Some(amount);
140 self.updated_at = Utc::now();
141 }
142
143 pub fn clear_goal(&mut self) {
145 self.goal_amount = None;
146 self.updated_at = Utc::now();
147 }
148
149 pub fn move_to_group(&mut self, group_id: CategoryGroupId) {
151 self.group_id = group_id;
152 self.updated_at = Utc::now();
153 }
154
155 pub fn validate(&self) -> Result<(), CategoryValidationError> {
157 if self.name.trim().is_empty() {
158 return Err(CategoryValidationError::EmptyName);
159 }
160
161 if self.name.len() > 50 {
162 return Err(CategoryValidationError::NameTooLong(self.name.len()));
163 }
164
165 if let Some(goal) = self.goal_amount {
166 if goal < 0 {
167 return Err(CategoryValidationError::NegativeGoal);
168 }
169 }
170
171 Ok(())
172 }
173}
174
175impl fmt::Display for Category {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 write!(f, "{}", self.name)
178 }
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum DefaultCategoryGroup {
184 Bills,
185 Needs,
186 Wants,
187 Savings,
188}
189
190impl DefaultCategoryGroup {
191 pub fn all() -> &'static [Self] {
193 &[Self::Bills, Self::Needs, Self::Wants, Self::Savings]
194 }
195
196 pub fn name(&self) -> &'static str {
198 match self {
199 Self::Bills => "Bills",
200 Self::Needs => "Needs",
201 Self::Wants => "Wants",
202 Self::Savings => "Savings",
203 }
204 }
205
206 pub fn to_group(&self, sort_order: i32) -> CategoryGroup {
208 CategoryGroup::with_sort_order(self.name(), sort_order)
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum CategoryValidationError {
215 EmptyName,
216 NameTooLong(usize),
217 NegativeGoal,
218}
219
220impl fmt::Display for CategoryValidationError {
221 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222 match self {
223 Self::EmptyName => write!(f, "Category name cannot be empty"),
224 Self::NameTooLong(len) => {
225 write!(f, "Category name too long ({} chars, max 50)", len)
226 }
227 Self::NegativeGoal => write!(f, "Goal amount cannot be negative"),
228 }
229 }
230}
231
232impl std::error::Error for CategoryValidationError {}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn test_new_group() {
240 let group = CategoryGroup::new("Bills");
241 assert_eq!(group.name, "Bills");
242 assert_eq!(group.sort_order, 0);
243 assert!(!group.hidden);
244 }
245
246 #[test]
247 fn test_new_category() {
248 let group = CategoryGroup::new("Bills");
249 let category = Category::new("Rent", group.id);
250
251 assert_eq!(category.name, "Rent");
252 assert_eq!(category.group_id, group.id);
253 assert!(!category.hidden);
254 assert!(category.goal_amount.is_none());
255 }
256
257 #[test]
258 fn test_category_goal() {
259 let group = CategoryGroup::new("Savings");
260 let mut category = Category::new("Emergency Fund", group.id);
261
262 category.set_goal(100000); assert_eq!(category.goal_amount, Some(100000));
264
265 category.clear_goal();
266 assert!(category.goal_amount.is_none());
267 }
268
269 #[test]
270 fn test_group_validation() {
271 let mut group = CategoryGroup::new("Valid");
272 assert!(group.validate().is_ok());
273
274 group.name = String::new();
275 assert_eq!(group.validate(), Err(CategoryValidationError::EmptyName));
276
277 group.name = "a".repeat(51);
278 assert!(matches!(
279 group.validate(),
280 Err(CategoryValidationError::NameTooLong(_))
281 ));
282 }
283
284 #[test]
285 fn test_category_validation() {
286 let group = CategoryGroup::new("Test");
287 let mut category = Category::new("Valid", group.id);
288 assert!(category.validate().is_ok());
289
290 category.name = String::new();
291 assert_eq!(category.validate(), Err(CategoryValidationError::EmptyName));
292
293 category.name = "Valid".to_string();
294 category.goal_amount = Some(-100);
295 assert_eq!(
296 category.validate(),
297 Err(CategoryValidationError::NegativeGoal)
298 );
299 }
300
301 #[test]
302 fn test_default_groups() {
303 let defaults = DefaultCategoryGroup::all();
304 assert_eq!(defaults.len(), 4);
305 assert_eq!(defaults[0].name(), "Bills");
306 assert_eq!(defaults[1].name(), "Needs");
307 }
308
309 #[test]
310 fn test_move_category() {
311 let group1 = CategoryGroup::new("Group 1");
312 let group2 = CategoryGroup::new("Group 2");
313 let mut category = Category::new("Test", group1.id);
314
315 assert_eq!(category.group_id, group1.id);
316
317 category.move_to_group(group2.id);
318 assert_eq!(category.group_id, group2.id);
319 }
320
321 #[test]
322 fn test_serialization() {
323 let group = CategoryGroup::new("Test Group");
324 let json = serde_json::to_string(&group).unwrap();
325 let deserialized: CategoryGroup = serde_json::from_str(&json).unwrap();
326 assert_eq!(group.id, deserialized.id);
327 assert_eq!(group.name, deserialized.name);
328
329 let category = Category::new("Test Category", group.id);
330 let json = serde_json::to_string(&category).unwrap();
331 let deserialized: Category = serde_json::from_str(&json).unwrap();
332 assert_eq!(category.id, deserialized.id);
333 assert_eq!(category.name, deserialized.name);
334 }
335}