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 #[serde(with = "crate::serde_timestamp::utc")]
146 pub timestamp: DateTime<Utc>,
147 pub severity: RiskLevel,
149}
150
151impl SodViolation {
152 pub fn new(
154 conflict_type: SodConflictType,
155 actor_id: impl Into<String>,
156 conflicting_action: impl Into<String>,
157 severity: RiskLevel,
158 ) -> Self {
159 Self {
160 conflict_type,
161 actor_id: actor_id.into(),
162 conflicting_action: conflicting_action.into(),
163 timestamp: Utc::now(),
164 severity,
165 }
166 }
167
168 pub fn with_timestamp(
170 conflict_type: SodConflictType,
171 actor_id: impl Into<String>,
172 conflicting_action: impl Into<String>,
173 severity: RiskLevel,
174 timestamp: DateTime<Utc>,
175 ) -> Self {
176 Self {
177 conflict_type,
178 actor_id: actor_id.into(),
179 conflicting_action: conflicting_action.into(),
180 timestamp,
181 severity,
182 }
183 }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SodRule {
189 pub rule_id: String,
191 pub name: String,
193 pub conflict_type: SodConflictType,
195 pub description: String,
197 pub is_active: bool,
199 pub risk_level: RiskLevel,
201}
202
203impl SodRule {
204 pub fn new(
206 rule_id: impl Into<String>,
207 name: impl Into<String>,
208 conflict_type: SodConflictType,
209 ) -> Self {
210 Self {
211 rule_id: rule_id.into(),
212 name: name.into(),
213 conflict_type,
214 description: String::new(),
215 is_active: true,
216 risk_level: RiskLevel::High,
217 }
218 }
219
220 pub fn with_description(mut self, description: impl Into<String>) -> Self {
222 self.description = description.into();
223 self
224 }
225
226 pub fn with_risk_level(mut self, level: RiskLevel) -> Self {
228 self.risk_level = level;
229 self
230 }
231
232 pub fn standard_rules() -> Vec<Self> {
234 vec![
235 Self::new(
236 "SOD001",
237 "Preparer-Approver Conflict",
238 SodConflictType::PreparerApprover,
239 )
240 .with_description("User cannot approve their own journal entries")
241 .with_risk_level(RiskLevel::High),
242 Self::new(
243 "SOD002",
244 "Payment Dual Control",
245 SodConflictType::PaymentReleaser,
246 )
247 .with_description("User cannot both create and release the same payment")
248 .with_risk_level(RiskLevel::Critical),
249 Self::new(
250 "SOD003",
251 "Vendor Master-Payment Conflict",
252 SodConflictType::MasterDataMaintainer,
253 )
254 .with_description("User cannot maintain vendor master data and process payments")
255 .with_risk_level(RiskLevel::High),
256 Self::new(
257 "SOD004",
258 "Requester-Approver Conflict",
259 SodConflictType::RequesterApprover,
260 )
261 .with_description("User cannot approve their own requisitions or expenses")
262 .with_risk_level(RiskLevel::Critical),
263 Self::new(
264 "SOD005",
265 "Reconciler-Poster Conflict",
266 SodConflictType::ReconcilerPoster,
267 )
268 .with_description("User cannot both reconcile accounts and post adjusting entries")
269 .with_risk_level(RiskLevel::Medium),
270 ]
271 }
272}
273
274#[cfg(test)]
275#[allow(clippy::unwrap_used)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn test_sod_conflict_display() {
281 assert_eq!(
282 SodConflictType::PreparerApprover.to_string(),
283 "Preparer/Approver"
284 );
285 assert_eq!(
286 SodConflictType::PaymentReleaser.to_string(),
287 "Payment Releaser"
288 );
289 }
290
291 #[test]
292 fn test_standard_conflicts() {
293 let conflicts = SodConflictPair::standard_conflicts();
294 assert!(!conflicts.is_empty());
295
296 let critical: Vec<_> = conflicts
298 .iter()
299 .filter(|c| c.severity == RiskLevel::Critical)
300 .collect();
301 assert!(!critical.is_empty());
302 }
303
304 #[test]
305 fn test_sod_violation_creation() {
306 let violation = SodViolation::new(
307 SodConflictType::PreparerApprover,
308 "USER001",
309 "Approved own journal entry",
310 RiskLevel::High,
311 );
312
313 assert_eq!(violation.actor_id, "USER001");
314 assert_eq!(violation.conflict_type, SodConflictType::PreparerApprover);
315 }
316
317 #[test]
318 fn test_standard_rules() {
319 let rules = SodRule::standard_rules();
320 assert!(!rules.is_empty());
321
322 assert!(rules.iter().all(|r| r.is_active));
324 }
325}