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