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