1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum Operation {
13 Create,
15 Update,
17 Delete,
19}
20
21impl std::fmt::Display for Operation {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 match self {
24 Operation::Create => write!(f, "CREATE"),
25 Operation::Update => write!(f, "UPDATE"),
26 Operation::Delete => write!(f, "DELETE"),
27 }
28 }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum EntityType {
35 Account,
36 Transaction,
37 Category,
38 CategoryGroup,
39 BudgetAllocation,
40 BudgetTarget,
41 Payee,
42}
43
44impl std::fmt::Display for EntityType {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 match self {
47 EntityType::Account => write!(f, "Account"),
48 EntityType::Transaction => write!(f, "Transaction"),
49 EntityType::Category => write!(f, "Category"),
50 EntityType::CategoryGroup => write!(f, "CategoryGroup"),
51 EntityType::BudgetAllocation => write!(f, "BudgetAllocation"),
52 EntityType::BudgetTarget => write!(f, "BudgetTarget"),
53 EntityType::Payee => write!(f, "Payee"),
54 }
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct AuditEntry {
64 pub timestamp: DateTime<Utc>,
66
67 pub operation: Operation,
69
70 pub entity_type: EntityType,
72
73 pub entity_id: String,
75
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub entity_name: Option<String>,
79
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub before: Option<serde_json::Value>,
83
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub after: Option<serde_json::Value>,
87
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub diff_summary: Option<String>,
91}
92
93impl AuditEntry {
94 pub fn create<T: Serialize>(
96 entity_type: EntityType,
97 entity_id: impl Into<String>,
98 entity_name: Option<String>,
99 entity: &T,
100 ) -> Self {
101 Self {
102 timestamp: Utc::now(),
103 operation: Operation::Create,
104 entity_type,
105 entity_id: entity_id.into(),
106 entity_name,
107 before: None,
108 after: serde_json::to_value(entity).ok(),
109 diff_summary: None,
110 }
111 }
112
113 pub fn update<T: Serialize>(
115 entity_type: EntityType,
116 entity_id: impl Into<String>,
117 entity_name: Option<String>,
118 before: &T,
119 after: &T,
120 diff_summary: Option<String>,
121 ) -> Self {
122 Self {
123 timestamp: Utc::now(),
124 operation: Operation::Update,
125 entity_type,
126 entity_id: entity_id.into(),
127 entity_name,
128 before: serde_json::to_value(before).ok(),
129 after: serde_json::to_value(after).ok(),
130 diff_summary,
131 }
132 }
133
134 pub fn delete<T: Serialize>(
136 entity_type: EntityType,
137 entity_id: impl Into<String>,
138 entity_name: Option<String>,
139 entity: &T,
140 ) -> Self {
141 Self {
142 timestamp: Utc::now(),
143 operation: Operation::Delete,
144 entity_type,
145 entity_id: entity_id.into(),
146 entity_name,
147 before: serde_json::to_value(entity).ok(),
148 after: None,
149 diff_summary: None,
150 }
151 }
152
153 pub fn format_human_readable(&self) -> String {
155 let mut output = format!(
156 "[{}] {} {} {}",
157 self.timestamp.format("%Y-%m-%d %H:%M:%S UTC"),
158 self.operation,
159 self.entity_type,
160 self.entity_id
161 );
162
163 if let Some(name) = &self.entity_name {
164 output.push_str(&format!(" ({})", name));
165 }
166
167 if let Some(diff) = &self.diff_summary {
168 output.push_str(&format!("\n Changes: {}", diff));
169 }
170
171 output
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use serde_json::json;
179
180 #[test]
181 fn test_operation_display() {
182 assert_eq!(Operation::Create.to_string(), "CREATE");
183 assert_eq!(Operation::Update.to_string(), "UPDATE");
184 assert_eq!(Operation::Delete.to_string(), "DELETE");
185 }
186
187 #[test]
188 fn test_entity_type_display() {
189 assert_eq!(EntityType::Account.to_string(), "Account");
190 assert_eq!(EntityType::Transaction.to_string(), "Transaction");
191 }
192
193 #[test]
194 fn test_create_entry() {
195 let data = json!({"name": "Checking", "balance": 1000});
196 let entry = AuditEntry::create(
197 EntityType::Account,
198 "acc-12345678",
199 Some("Checking".to_string()),
200 &data,
201 );
202
203 assert_eq!(entry.operation, Operation::Create);
204 assert_eq!(entry.entity_type, EntityType::Account);
205 assert_eq!(entry.entity_id, "acc-12345678");
206 assert!(entry.before.is_none());
207 assert!(entry.after.is_some());
208 }
209
210 #[test]
211 fn test_update_entry() {
212 let before = json!({"name": "Checking", "balance": 1000});
213 let after = json!({"name": "Checking", "balance": 1500});
214
215 let entry = AuditEntry::update(
216 EntityType::Account,
217 "acc-12345678",
218 Some("Checking".to_string()),
219 &before,
220 &after,
221 Some("balance: 1000 -> 1500".to_string()),
222 );
223
224 assert_eq!(entry.operation, Operation::Update);
225 assert!(entry.before.is_some());
226 assert!(entry.after.is_some());
227 assert_eq!(
228 entry.diff_summary,
229 Some("balance: 1000 -> 1500".to_string())
230 );
231 }
232
233 #[test]
234 fn test_delete_entry() {
235 let data = json!({"name": "Old Account"});
236 let entry = AuditEntry::delete(
237 EntityType::Account,
238 "acc-12345678",
239 Some("Old Account".to_string()),
240 &data,
241 );
242
243 assert_eq!(entry.operation, Operation::Delete);
244 assert!(entry.before.is_some());
245 assert!(entry.after.is_none());
246 }
247
248 #[test]
249 fn test_serialization() {
250 let data = json!({"name": "Test"});
251 let entry = AuditEntry::create(EntityType::Account, "acc-123", None, &data);
252
253 let json = serde_json::to_string(&entry).unwrap();
254 let deserialized: AuditEntry = serde_json::from_str(&json).unwrap();
255
256 assert_eq!(deserialized.operation, Operation::Create);
257 assert_eq!(deserialized.entity_type, EntityType::Account);
258 }
259
260 #[test]
261 fn test_human_readable_format() {
262 let data = json!({"name": "Checking"});
263 let entry = AuditEntry::create(
264 EntityType::Account,
265 "acc-12345678",
266 Some("Checking".to_string()),
267 &data,
268 );
269
270 let formatted = entry.format_human_readable();
271 assert!(formatted.contains("CREATE"));
272 assert!(formatted.contains("Account"));
273 assert!(formatted.contains("acc-12345678"));
274 assert!(formatted.contains("Checking"));
275 }
276}