1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use super::internal_control::RiskLevel;
10use super::user::UserPersona;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum SodConflictType {
16 PreparerApprover,
18 RequesterApprover,
20 ReconcilerPoster,
22 MasterDataMaintainer,
24 PaymentReleaser,
26 JournalEntryPoster,
28 SystemAccessConflict,
30}
31
32impl std::fmt::Display for SodConflictType {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 match self {
35 Self::PreparerApprover => write!(f, "Preparer/Approver"),
36 Self::RequesterApprover => write!(f, "Requester/Approver"),
37 Self::ReconcilerPoster => write!(f, "Reconciler/Poster"),
38 Self::MasterDataMaintainer => write!(f, "Master Data Maintainer"),
39 Self::PaymentReleaser => write!(f, "Payment Releaser"),
40 Self::JournalEntryPoster => write!(f, "Journal Entry Poster"),
41 Self::SystemAccessConflict => write!(f, "System Access Conflict"),
42 }
43 }
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct SodConflictPair {
49 pub conflict_type: SodConflictType,
51 pub role_a: UserPersona,
53 pub role_b: UserPersona,
55 pub description: String,
57 pub severity: RiskLevel,
59}
60
61impl SodConflictPair {
62 pub fn new(
64 conflict_type: SodConflictType,
65 role_a: UserPersona,
66 role_b: UserPersona,
67 description: impl Into<String>,
68 severity: RiskLevel,
69 ) -> Self {
70 Self {
71 conflict_type,
72 role_a,
73 role_b,
74 description: description.into(),
75 severity,
76 }
77 }
78
79 pub fn standard_conflicts() -> Vec<Self> {
81 vec![
82 Self::new(
83 SodConflictType::PreparerApprover,
84 UserPersona::JuniorAccountant,
85 UserPersona::SeniorAccountant,
86 "Same person prepared and approved journal entry",
87 RiskLevel::High,
88 ),
89 Self::new(
90 SodConflictType::PreparerApprover,
91 UserPersona::SeniorAccountant,
92 UserPersona::Controller,
93 "Same person prepared and approved high-value transaction",
94 RiskLevel::High,
95 ),
96 Self::new(
97 SodConflictType::RequesterApprover,
98 UserPersona::JuniorAccountant,
99 UserPersona::Manager,
100 "Same person requested and approved their own expense/requisition",
101 RiskLevel::Critical,
102 ),
103 Self::new(
104 SodConflictType::PaymentReleaser,
105 UserPersona::SeniorAccountant,
106 UserPersona::SeniorAccountant,
107 "Same person created and released payment",
108 RiskLevel::Critical,
109 ),
110 Self::new(
111 SodConflictType::MasterDataMaintainer,
112 UserPersona::SeniorAccountant,
113 UserPersona::JuniorAccountant,
114 "Same person maintains vendor master and processes payments",
115 RiskLevel::High,
116 ),
117 Self::new(
118 SodConflictType::ReconcilerPoster,
119 UserPersona::JuniorAccountant,
120 UserPersona::JuniorAccountant,
121 "Same person performed account reconciliation and posted adjustments",
122 RiskLevel::Medium,
123 ),
124 Self::new(
125 SodConflictType::JournalEntryPoster,
126 UserPersona::JuniorAccountant,
127 UserPersona::JuniorAccountant,
128 "Posted to sensitive GL accounts without independent review",
129 RiskLevel::High,
130 ),
131 ]
132 }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct SodViolation {
138 pub conflict_type: SodConflictType,
140 pub actor_id: String,
142 pub conflicting_action: String,
144 pub timestamp: DateTime<Utc>,
146 pub severity: RiskLevel,
148}
149
150impl SodViolation {
151 pub fn new(
153 conflict_type: SodConflictType,
154 actor_id: impl Into<String>,
155 conflicting_action: impl Into<String>,
156 severity: RiskLevel,
157 ) -> Self {
158 Self {
159 conflict_type,
160 actor_id: actor_id.into(),
161 conflicting_action: conflicting_action.into(),
162 timestamp: Utc::now(),
163 severity,
164 }
165 }
166
167 pub fn with_timestamp(
169 conflict_type: SodConflictType,
170 actor_id: impl Into<String>,
171 conflicting_action: impl Into<String>,
172 severity: RiskLevel,
173 timestamp: DateTime<Utc>,
174 ) -> Self {
175 Self {
176 conflict_type,
177 actor_id: actor_id.into(),
178 conflicting_action: conflicting_action.into(),
179 timestamp,
180 severity,
181 }
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct SodRule {
188 pub rule_id: String,
190 pub name: String,
192 pub conflict_type: SodConflictType,
194 pub description: String,
196 pub is_active: bool,
198 pub risk_level: RiskLevel,
200}
201
202impl SodRule {
203 pub fn new(
205 rule_id: impl Into<String>,
206 name: impl Into<String>,
207 conflict_type: SodConflictType,
208 ) -> Self {
209 Self {
210 rule_id: rule_id.into(),
211 name: name.into(),
212 conflict_type,
213 description: String::new(),
214 is_active: true,
215 risk_level: RiskLevel::High,
216 }
217 }
218
219 pub fn with_description(mut self, description: impl Into<String>) -> Self {
221 self.description = description.into();
222 self
223 }
224
225 pub fn with_risk_level(mut self, level: RiskLevel) -> Self {
227 self.risk_level = level;
228 self
229 }
230
231 pub fn standard_rules() -> Vec<Self> {
233 vec![
234 Self::new(
235 "SOD001",
236 "Preparer-Approver Conflict",
237 SodConflictType::PreparerApprover,
238 )
239 .with_description("User cannot approve their own journal entries")
240 .with_risk_level(RiskLevel::High),
241 Self::new(
242 "SOD002",
243 "Payment Dual Control",
244 SodConflictType::PaymentReleaser,
245 )
246 .with_description("User cannot both create and release the same payment")
247 .with_risk_level(RiskLevel::Critical),
248 Self::new(
249 "SOD003",
250 "Vendor Master-Payment Conflict",
251 SodConflictType::MasterDataMaintainer,
252 )
253 .with_description("User cannot maintain vendor master data and process payments")
254 .with_risk_level(RiskLevel::High),
255 Self::new(
256 "SOD004",
257 "Requester-Approver Conflict",
258 SodConflictType::RequesterApprover,
259 )
260 .with_description("User cannot approve their own requisitions or expenses")
261 .with_risk_level(RiskLevel::Critical),
262 Self::new(
263 "SOD005",
264 "Reconciler-Poster Conflict",
265 SodConflictType::ReconcilerPoster,
266 )
267 .with_description("User cannot both reconcile accounts and post adjusting entries")
268 .with_risk_level(RiskLevel::Medium),
269 ]
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn test_sod_conflict_display() {
279 assert_eq!(
280 SodConflictType::PreparerApprover.to_string(),
281 "Preparer/Approver"
282 );
283 assert_eq!(
284 SodConflictType::PaymentReleaser.to_string(),
285 "Payment Releaser"
286 );
287 }
288
289 #[test]
290 fn test_standard_conflicts() {
291 let conflicts = SodConflictPair::standard_conflicts();
292 assert!(!conflicts.is_empty());
293
294 let critical: Vec<_> = conflicts
296 .iter()
297 .filter(|c| c.severity == RiskLevel::Critical)
298 .collect();
299 assert!(!critical.is_empty());
300 }
301
302 #[test]
303 fn test_sod_violation_creation() {
304 let violation = SodViolation::new(
305 SodConflictType::PreparerApprover,
306 "USER001",
307 "Approved own journal entry",
308 RiskLevel::High,
309 );
310
311 assert_eq!(violation.actor_id, "USER001");
312 assert_eq!(violation.conflict_type, SodConflictType::PreparerApprover);
313 }
314
315 #[test]
316 fn test_standard_rules() {
317 let rules = SodRule::standard_rules();
318 assert!(!rules.is_empty());
319
320 assert!(rules.iter().all(|r| r.is_active));
322 }
323}