Skip to main content

datasynth_core/models/audit/
engagement.rs

1//! Audit engagement models per ISA 210 and ISA 220.
2//!
3//! An audit engagement represents the entire audit project including
4//! planning, fieldwork, and reporting phases.
5
6use 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/// Audit engagement representing an audit project.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AuditEngagement {
17    /// Unique engagement ID
18    pub engagement_id: Uuid,
19    /// External engagement reference (e.g., "AUD-2025-001")
20    pub engagement_ref: String,
21    /// Client entity being audited
22    pub client_entity_id: String,
23    /// Client name
24    pub client_name: String,
25    /// Type of engagement
26    pub engagement_type: EngagementType,
27    /// Fiscal year being audited
28    pub fiscal_year: u16,
29    /// Fiscal period end date
30    pub period_end_date: NaiveDate,
31
32    // === Materiality ===
33    /// Overall materiality
34    pub materiality: Decimal,
35    /// Performance materiality (typically 50-75% of materiality)
36    pub performance_materiality: Decimal,
37    /// Clearly trivial threshold (typically 3-5% of materiality)
38    pub clearly_trivial: Decimal,
39    /// Materiality basis (e.g., "Total Revenue", "Total Assets")
40    pub materiality_basis: String,
41    /// Materiality percentage applied
42    pub materiality_percentage: f64,
43
44    // === Timeline ===
45    /// Planning phase start date
46    pub planning_start: NaiveDate,
47    /// Planning phase end date
48    pub planning_end: NaiveDate,
49    /// Fieldwork start date
50    pub fieldwork_start: NaiveDate,
51    /// Fieldwork end date
52    pub fieldwork_end: NaiveDate,
53    /// Completion phase start date
54    pub completion_start: NaiveDate,
55    /// Expected report date
56    pub report_date: NaiveDate,
57
58    // === Team ===
59    /// Engagement partner ID
60    pub engagement_partner_id: String,
61    /// Engagement partner name
62    pub engagement_partner_name: String,
63    /// Engagement manager ID
64    pub engagement_manager_id: String,
65    /// Engagement manager name
66    pub engagement_manager_name: String,
67    /// All team member IDs
68    pub team_member_ids: Vec<String>,
69
70    // === Status ===
71    /// Current engagement status
72    pub status: EngagementStatus,
73    /// Current phase
74    pub current_phase: EngagementPhase,
75
76    // === Risk Assessment Summary ===
77    /// Overall audit risk assessment
78    pub overall_audit_risk: RiskLevel,
79    /// Number of significant risks identified
80    pub significant_risk_count: u32,
81    /// Fraud risk assessment level
82    pub fraud_risk_level: RiskLevel,
83
84    // === Timestamps ===
85    pub created_at: DateTime<Utc>,
86    pub updated_at: DateTime<Utc>,
87}
88
89impl AuditEngagement {
90    /// Create a new audit engagement.
91    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    /// Set materiality values.
134    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    /// Set the engagement team.
153    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    /// Set engagement timeline.
170    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    /// Advance to the next phase.
189    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    /// Check if the engagement is complete.
202    pub fn is_complete(&self) -> bool {
203        matches!(
204            self.status,
205            EngagementStatus::Complete | EngagementStatus::Archived
206        )
207    }
208
209    /// Calculate days until report date.
210    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/// Type of audit engagement.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
290#[serde(rename_all = "snake_case")]
291pub enum EngagementType {
292    /// Annual financial statement audit
293    #[default]
294    AnnualAudit,
295    /// Interim audit procedures
296    InterimAudit,
297    /// SOX 404 internal control audit
298    Sox404,
299    /// Integrated audit (financial statements + SOX)
300    IntegratedAudit,
301    /// Review engagement (limited assurance)
302    ReviewEngagement,
303    /// Compilation engagement (no assurance)
304    CompilationEngagement,
305    /// Agreed-upon procedures
306    AgreedUponProcedures,
307    /// Special purpose audit
308    SpecialPurpose,
309}
310
311impl EngagementType {
312    /// Get the assurance level for this engagement type.
313    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    /// Check if this requires SOX compliance testing.
326    pub fn requires_sox_testing(&self) -> bool {
327        matches!(self, Self::Sox404 | Self::IntegratedAudit)
328    }
329}
330
331/// Level of assurance provided.
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
333#[serde(rename_all = "snake_case")]
334pub enum AssuranceLevel {
335    /// Reasonable assurance (audit)
336    Reasonable,
337    /// Limited assurance (review)
338    Limited,
339    /// No assurance (compilation, AUP)
340    None,
341}
342
343/// Engagement status.
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
345#[serde(rename_all = "snake_case")]
346pub enum EngagementStatus {
347    /// Planning in progress
348    #[default]
349    Planning,
350    /// Active fieldwork
351    InProgress,
352    /// Under review
353    UnderReview,
354    /// Pending partner sign-off
355    PendingSignOff,
356    /// Complete
357    Complete,
358    /// Archived
359    Archived,
360    /// On hold
361    OnHold,
362}
363
364/// Current engagement phase.
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
366#[serde(rename_all = "snake_case")]
367pub enum EngagementPhase {
368    /// Planning and understanding the entity
369    #[default]
370    Planning,
371    /// Risk assessment procedures
372    RiskAssessment,
373    /// Testing of controls
374    ControlTesting,
375    /// Substantive testing
376    SubstantiveTesting,
377    /// Completion procedures
378    Completion,
379    /// Report issuance
380    Reporting,
381}
382
383impl EngagementPhase {
384    /// Get the phase name for display.
385    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    /// Get the ISA reference for this phase.
397    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/// Risk level classification.
410#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
411#[serde(rename_all = "snake_case")]
412pub enum RiskLevel {
413    /// Low risk
414    Low,
415    /// Medium risk
416    #[default]
417    Medium,
418    /// High risk
419    High,
420    /// Significant risk (per ISA 315)
421    Significant,
422}
423
424impl RiskLevel {
425    /// Get numeric score for calculations.
426    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    /// Create from numeric score.
436    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/// Engagement team member role.
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct EngagementTeamMember {
449    /// Team member ID
450    pub member_id: String,
451    /// Team member name
452    pub name: String,
453    /// Role on the engagement
454    pub role: TeamMemberRole,
455    /// Allocated hours
456    pub allocated_hours: f64,
457    /// Actual hours worked
458    pub actual_hours: f64,
459    /// Sections assigned
460    pub assigned_sections: Vec<String>,
461}
462
463/// Role of a team member.
464#[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}