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