1use chrono::{DateTime, NaiveDate, Utc};
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use uuid::Uuid;
11
12use super::super::graph_properties::{GraphPropertyValue, ToNodeProperties};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AuditEngagement {
17 pub engagement_id: Uuid,
19 pub engagement_ref: String,
21 pub client_entity_id: String,
23 pub client_name: String,
25 pub engagement_type: EngagementType,
27 pub fiscal_year: u16,
29 pub period_end_date: NaiveDate,
31
32 pub materiality: Decimal,
35 pub performance_materiality: Decimal,
37 pub clearly_trivial: Decimal,
39 pub materiality_basis: String,
41 pub materiality_percentage: f64,
43
44 pub planning_start: NaiveDate,
47 pub planning_end: NaiveDate,
49 pub fieldwork_start: NaiveDate,
51 pub fieldwork_end: NaiveDate,
53 pub completion_start: NaiveDate,
55 pub report_date: NaiveDate,
57
58 pub engagement_partner_id: String,
61 pub engagement_partner_name: String,
63 pub engagement_manager_id: String,
65 pub engagement_manager_name: String,
67 pub team_member_ids: Vec<String>,
69
70 pub status: EngagementStatus,
73 pub current_phase: EngagementPhase,
75
76 pub overall_audit_risk: RiskLevel,
79 pub significant_risk_count: u32,
81 pub fraud_risk_level: RiskLevel,
83
84 pub created_at: DateTime<Utc>,
86 pub updated_at: DateTime<Utc>,
87
88 #[serde(default)]
91 pub scope_id: Option<String>,
92}
93
94impl AuditEngagement {
95 pub fn new(
97 client_entity_id: &str,
98 client_name: &str,
99 engagement_type: EngagementType,
100 fiscal_year: u16,
101 period_end_date: NaiveDate,
102 ) -> Self {
103 let now = Utc::now();
104 Self {
105 engagement_id: Uuid::new_v4(),
106 engagement_ref: format!("AUD-{}-{:03}", fiscal_year, 1),
107 client_entity_id: client_entity_id.into(),
108 client_name: client_name.into(),
109 engagement_type,
110 fiscal_year,
111 period_end_date,
112 materiality: Decimal::ZERO,
113 performance_materiality: Decimal::ZERO,
114 clearly_trivial: Decimal::ZERO,
115 materiality_basis: String::new(),
116 materiality_percentage: 0.0,
117 planning_start: period_end_date,
118 planning_end: period_end_date,
119 fieldwork_start: period_end_date,
120 fieldwork_end: period_end_date,
121 completion_start: period_end_date,
122 report_date: period_end_date,
123 engagement_partner_id: String::new(),
124 engagement_partner_name: String::new(),
125 engagement_manager_id: String::new(),
126 engagement_manager_name: String::new(),
127 team_member_ids: Vec::new(),
128 status: EngagementStatus::Planning,
129 current_phase: EngagementPhase::Planning,
130 overall_audit_risk: RiskLevel::Medium,
131 significant_risk_count: 0,
132 fraud_risk_level: RiskLevel::Low,
133 created_at: now,
134 updated_at: now,
135 scope_id: None,
136 }
137 }
138
139 pub fn with_materiality(
141 mut self,
142 materiality: Decimal,
143 performance_materiality_factor: f64,
144 clearly_trivial_factor: f64,
145 basis: &str,
146 percentage: f64,
147 ) -> Self {
148 self.materiality = materiality;
149 self.performance_materiality =
150 materiality * Decimal::try_from(performance_materiality_factor).unwrap_or_default();
151 self.clearly_trivial =
152 materiality * Decimal::try_from(clearly_trivial_factor).unwrap_or_default();
153 self.materiality_basis = basis.into();
154 self.materiality_percentage = percentage;
155 self
156 }
157
158 pub fn with_team(
160 mut self,
161 partner_id: &str,
162 partner_name: &str,
163 manager_id: &str,
164 manager_name: &str,
165 team_members: Vec<String>,
166 ) -> Self {
167 self.engagement_partner_id = partner_id.into();
168 self.engagement_partner_name = partner_name.into();
169 self.engagement_manager_id = manager_id.into();
170 self.engagement_manager_name = manager_name.into();
171 self.team_member_ids = team_members;
172 self
173 }
174
175 pub fn with_timeline(
177 mut self,
178 planning_start: NaiveDate,
179 planning_end: NaiveDate,
180 fieldwork_start: NaiveDate,
181 fieldwork_end: NaiveDate,
182 completion_start: NaiveDate,
183 report_date: NaiveDate,
184 ) -> Self {
185 self.planning_start = planning_start;
186 self.planning_end = planning_end;
187 self.fieldwork_start = fieldwork_start;
188 self.fieldwork_end = fieldwork_end;
189 self.completion_start = completion_start;
190 self.report_date = report_date;
191 self
192 }
193
194 pub fn advance_phase(&mut self) {
196 self.current_phase = match self.current_phase {
197 EngagementPhase::Planning => EngagementPhase::RiskAssessment,
198 EngagementPhase::RiskAssessment => EngagementPhase::ControlTesting,
199 EngagementPhase::ControlTesting => EngagementPhase::SubstantiveTesting,
200 EngagementPhase::SubstantiveTesting => EngagementPhase::Completion,
201 EngagementPhase::Completion => EngagementPhase::Reporting,
202 EngagementPhase::Reporting => EngagementPhase::Reporting,
203 };
204 self.updated_at = Utc::now();
205 }
206
207 pub fn is_complete(&self) -> bool {
209 matches!(
210 self.status,
211 EngagementStatus::Complete | EngagementStatus::Archived
212 )
213 }
214
215 pub fn days_until_report(&self, as_of: NaiveDate) -> i64 {
217 (self.report_date - as_of).num_days()
218 }
219}
220
221impl ToNodeProperties for AuditEngagement {
222 fn node_type_name(&self) -> &'static str {
223 "audit_engagement"
224 }
225 fn node_type_code(&self) -> u16 {
226 360
227 }
228 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
229 let mut p = HashMap::new();
230 p.insert(
231 "engagementId".into(),
232 GraphPropertyValue::String(self.engagement_id.to_string()),
233 );
234 p.insert(
235 "engagementRef".into(),
236 GraphPropertyValue::String(self.engagement_ref.clone()),
237 );
238 p.insert(
239 "clientName".into(),
240 GraphPropertyValue::String(self.client_name.clone()),
241 );
242 p.insert(
243 "entityCode".into(),
244 GraphPropertyValue::String(self.client_entity_id.clone()),
245 );
246 p.insert(
247 "engagementType".into(),
248 GraphPropertyValue::String(format!("{:?}", self.engagement_type)),
249 );
250 p.insert(
251 "fiscalYear".into(),
252 GraphPropertyValue::Int(self.fiscal_year as i64),
253 );
254 p.insert(
255 "periodEndDate".into(),
256 GraphPropertyValue::Date(self.period_end_date),
257 );
258 p.insert(
259 "materiality".into(),
260 GraphPropertyValue::Decimal(self.materiality),
261 );
262 p.insert(
263 "performanceMateriality".into(),
264 GraphPropertyValue::Decimal(self.performance_materiality),
265 );
266 p.insert(
267 "status".into(),
268 GraphPropertyValue::String(format!("{:?}", self.status)),
269 );
270 p.insert(
271 "currentPhase".into(),
272 GraphPropertyValue::String(self.current_phase.display_name().into()),
273 );
274 p.insert(
275 "overallAuditRisk".into(),
276 GraphPropertyValue::String(format!("{:?}", self.overall_audit_risk)),
277 );
278 p.insert(
279 "significantRiskCount".into(),
280 GraphPropertyValue::Int(self.significant_risk_count as i64),
281 );
282 p.insert(
283 "teamSize".into(),
284 GraphPropertyValue::Int(self.team_member_ids.len() as i64),
285 );
286 p.insert(
287 "isComplete".into(),
288 GraphPropertyValue::Bool(self.is_complete()),
289 );
290 p
291 }
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
296#[serde(rename_all = "snake_case")]
297pub enum EngagementType {
298 #[default]
300 AnnualAudit,
301 InterimAudit,
303 Sox404,
305 IntegratedAudit,
307 ReviewEngagement,
309 CompilationEngagement,
311 AgreedUponProcedures,
313 SpecialPurpose,
315}
316
317impl EngagementType {
318 pub fn assurance_level(&self) -> AssuranceLevel {
320 match self {
321 Self::AnnualAudit
322 | Self::InterimAudit
323 | Self::Sox404
324 | Self::IntegratedAudit
325 | Self::SpecialPurpose => AssuranceLevel::Reasonable,
326 Self::ReviewEngagement => AssuranceLevel::Limited,
327 Self::CompilationEngagement | Self::AgreedUponProcedures => AssuranceLevel::None,
328 }
329 }
330
331 pub fn requires_sox_testing(&self) -> bool {
333 matches!(self, Self::Sox404 | Self::IntegratedAudit)
334 }
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
339#[serde(rename_all = "snake_case")]
340pub enum AssuranceLevel {
341 Reasonable,
343 Limited,
345 None,
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
351#[serde(rename_all = "snake_case")]
352pub enum EngagementStatus {
353 #[default]
355 Planning,
356 InProgress,
358 UnderReview,
360 PendingSignOff,
362 Complete,
364 Archived,
366 OnHold,
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
372#[serde(rename_all = "snake_case")]
373pub enum EngagementPhase {
374 #[default]
376 Planning,
377 RiskAssessment,
379 ControlTesting,
381 SubstantiveTesting,
383 Completion,
385 Reporting,
387}
388
389impl EngagementPhase {
390 pub fn display_name(&self) -> &'static str {
392 match self {
393 Self::Planning => "Planning",
394 Self::RiskAssessment => "Risk Assessment",
395 Self::ControlTesting => "Control Testing",
396 Self::SubstantiveTesting => "Substantive Testing",
397 Self::Completion => "Completion",
398 Self::Reporting => "Reporting",
399 }
400 }
401
402 pub fn isa_reference(&self) -> &'static str {
404 match self {
405 Self::Planning => "ISA 300",
406 Self::RiskAssessment => "ISA 315",
407 Self::ControlTesting => "ISA 330",
408 Self::SubstantiveTesting => "ISA 330, ISA 500",
409 Self::Completion => "ISA 450, ISA 560",
410 Self::Reporting => "ISA 700",
411 }
412 }
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
417#[serde(rename_all = "snake_case")]
418pub enum RiskLevel {
419 Low,
421 #[default]
423 Medium,
424 High,
426 Significant,
428}
429
430impl RiskLevel {
431 pub fn score(&self) -> u8 {
433 match self {
434 Self::Low => 1,
435 Self::Medium => 2,
436 Self::High => 3,
437 Self::Significant => 4,
438 }
439 }
440
441 pub fn from_score(score: u8) -> Self {
443 match score {
444 0..=1 => Self::Low,
445 2 => Self::Medium,
446 3 => Self::High,
447 _ => Self::Significant,
448 }
449 }
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct EngagementTeamMember {
455 pub member_id: String,
457 pub name: String,
459 pub role: TeamMemberRole,
461 pub allocated_hours: f64,
463 pub actual_hours: f64,
465 pub assigned_sections: Vec<String>,
467}
468
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
471#[serde(rename_all = "snake_case")]
472pub enum TeamMemberRole {
473 EngagementPartner,
474 EngagementQualityReviewer,
475 EngagementManager,
476 Senior,
477 Staff,
478 Specialist,
479 ITAuditor,
480}
481
482#[cfg(test)]
483#[allow(clippy::unwrap_used)]
484mod tests {
485 use super::*;
486
487 #[test]
488 fn test_engagement_creation() {
489 let engagement = AuditEngagement::new(
490 "ENTITY001",
491 "Test Company Inc.",
492 EngagementType::AnnualAudit,
493 2025,
494 NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
495 );
496
497 assert_eq!(engagement.fiscal_year, 2025);
498 assert_eq!(engagement.engagement_type, EngagementType::AnnualAudit);
499 assert_eq!(engagement.status, EngagementStatus::Planning);
500 }
501
502 #[test]
503 fn test_engagement_with_materiality() {
504 let engagement = AuditEngagement::new(
505 "ENTITY001",
506 "Test Company Inc.",
507 EngagementType::AnnualAudit,
508 2025,
509 NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
510 )
511 .with_materiality(
512 Decimal::new(1_000_000, 0),
513 0.75,
514 0.05,
515 "Total Revenue",
516 0.005,
517 );
518
519 assert_eq!(engagement.materiality, Decimal::new(1_000_000, 0));
520 assert_eq!(engagement.performance_materiality, Decimal::new(750_000, 0));
521 assert_eq!(engagement.clearly_trivial, Decimal::new(50_000, 0));
522 }
523
524 #[test]
525 fn test_phase_advancement() {
526 let mut engagement = AuditEngagement::new(
527 "ENTITY001",
528 "Test Company Inc.",
529 EngagementType::AnnualAudit,
530 2025,
531 NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
532 );
533
534 assert_eq!(engagement.current_phase, EngagementPhase::Planning);
535 engagement.advance_phase();
536 assert_eq!(engagement.current_phase, EngagementPhase::RiskAssessment);
537 engagement.advance_phase();
538 assert_eq!(engagement.current_phase, EngagementPhase::ControlTesting);
539 }
540
541 #[test]
542 fn test_risk_level_score() {
543 assert_eq!(RiskLevel::Low.score(), 1);
544 assert_eq!(RiskLevel::Significant.score(), 4);
545 assert_eq!(RiskLevel::from_score(3), RiskLevel::High);
546 }
547}