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