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    #[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    // === Scope ===
91    /// Audit scope identifier (FK → AuditScope.id). Populated during planning phase.
92    #[serde(default)]
93    pub scope_id: Option<String>,
94}
95
96impl AuditEngagement {
97    /// Create a new audit engagement.
98    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    /// Set materiality values.
142    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    /// Set the engagement team.
161    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    /// Set engagement timeline.
178    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    /// Advance to the next phase.
197    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    /// Check if the engagement is complete.
210    pub fn is_complete(&self) -> bool {
211        matches!(
212            self.status,
213            EngagementStatus::Complete | EngagementStatus::Archived
214        )
215    }
216
217    /// Calculate days until report date.
218    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/// Type of audit engagement.
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
298#[serde(rename_all = "snake_case")]
299pub enum EngagementType {
300    /// Annual financial statement audit
301    #[default]
302    AnnualAudit,
303    /// Interim audit procedures
304    InterimAudit,
305    /// SOX 404 internal control audit
306    Sox404,
307    /// Integrated audit (financial statements + SOX)
308    IntegratedAudit,
309    /// Review engagement (limited assurance)
310    ReviewEngagement,
311    /// Compilation engagement (no assurance)
312    CompilationEngagement,
313    /// Agreed-upon procedures
314    AgreedUponProcedures,
315    /// Special purpose audit
316    SpecialPurpose,
317}
318
319impl EngagementType {
320    /// Get the assurance level for this engagement type.
321    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    /// Check if this requires SOX compliance testing.
334    pub fn requires_sox_testing(&self) -> bool {
335        matches!(self, Self::Sox404 | Self::IntegratedAudit)
336    }
337}
338
339/// Level of assurance provided.
340#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
341#[serde(rename_all = "snake_case")]
342pub enum AssuranceLevel {
343    /// Reasonable assurance (audit)
344    Reasonable,
345    /// Limited assurance (review)
346    Limited,
347    /// No assurance (compilation, AUP)
348    None,
349}
350
351/// Engagement status.
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
353#[serde(rename_all = "snake_case")]
354pub enum EngagementStatus {
355    /// Planning in progress
356    #[default]
357    Planning,
358    /// Active fieldwork
359    InProgress,
360    /// Under review
361    UnderReview,
362    /// Pending partner sign-off
363    PendingSignOff,
364    /// Complete
365    Complete,
366    /// Archived
367    Archived,
368    /// On hold
369    OnHold,
370}
371
372/// Current engagement phase.
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
374#[serde(rename_all = "snake_case")]
375pub enum EngagementPhase {
376    /// Planning and understanding the entity
377    #[default]
378    Planning,
379    /// Risk assessment procedures
380    RiskAssessment,
381    /// Testing of controls
382    ControlTesting,
383    /// Substantive testing
384    SubstantiveTesting,
385    /// Completion procedures
386    Completion,
387    /// Report issuance
388    Reporting,
389}
390
391impl EngagementPhase {
392    /// Get the phase name for display.
393    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    /// Get the ISA reference for this phase.
405    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/// Risk level classification.
418#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
419#[serde(rename_all = "snake_case")]
420pub enum RiskLevel {
421    /// Low risk
422    Low,
423    /// Medium risk
424    #[default]
425    Medium,
426    /// High risk
427    High,
428    /// Significant risk (per ISA 315)
429    Significant,
430}
431
432impl RiskLevel {
433    /// Get numeric score for calculations.
434    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    /// Create from numeric score.
444    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/// Engagement team member role.
455#[derive(Debug, Clone, Serialize, Deserialize)]
456pub struct EngagementTeamMember {
457    /// Team member ID
458    pub member_id: String,
459    /// Team member name
460    pub name: String,
461    /// Role on the engagement
462    pub role: TeamMemberRole,
463    /// Allocated hours
464    pub allocated_hours: f64,
465    /// Actual hours worked
466    pub actual_hours: f64,
467    /// Sections assigned
468    pub assigned_sections: Vec<String>,
469}
470
471/// Role of a team member.
472#[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}