Skip to main content

datasynth_core/models/audit/
judgment.rs

1//! Professional judgment models per ISA 200.
2//!
3//! Professional judgment is essential in applying audit standards and
4//! making informed decisions throughout the audit process.
5
6use chrono::{DateTime, NaiveDate, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use uuid::Uuid;
10
11use super::super::graph_properties::{GraphPropertyValue, ToNodeProperties};
12use super::engagement::RiskLevel;
13
14/// Professional judgment documentation.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ProfessionalJudgment {
17    /// Unique judgment ID
18    pub judgment_id: Uuid,
19    /// External reference
20    pub judgment_ref: String,
21    /// Engagement ID
22    pub engagement_id: Uuid,
23    /// Type of judgment
24    pub judgment_type: JudgmentType,
25    /// Subject matter of the judgment
26    pub subject: String,
27    /// Applicable auditing standards
28    pub applicable_standards: Vec<String>,
29
30    // === Structured Documentation ===
31    /// Issue or matter requiring judgment
32    pub issue_description: String,
33    /// Information and factors considered
34    pub information_considered: Vec<InformationItem>,
35    /// Alternatives evaluated
36    pub alternatives_evaluated: Vec<AlternativeEvaluation>,
37    /// Professional skepticism applied
38    pub skepticism_applied: SkepticismDocumentation,
39
40    // === Conclusion ===
41    /// Conclusion reached
42    pub conclusion: String,
43    /// Rationale for conclusion
44    pub rationale: String,
45    /// Residual risk or uncertainty
46    pub residual_risk: String,
47    /// Impact on audit approach
48    pub impact_on_audit: Option<String>,
49
50    // === Consultation ===
51    /// Was consultation required?
52    pub consultation_required: bool,
53    /// Consultation details
54    pub consultation: Option<ConsultationRecord>,
55
56    // === Sign-offs ===
57    /// Preparer user ID
58    pub preparer_id: String,
59    /// Preparer name
60    pub preparer_name: String,
61    /// Date prepared
62    pub preparer_date: NaiveDate,
63    /// Reviewer ID
64    pub reviewer_id: Option<String>,
65    /// Reviewer name
66    pub reviewer_name: Option<String>,
67    /// Review date
68    pub reviewer_date: Option<NaiveDate>,
69    /// Partner concurrence required?
70    pub partner_concurrence_required: bool,
71    /// Partner concurrence ID
72    pub partner_concurrence_id: Option<String>,
73    /// Partner concurrence date
74    pub partner_concurrence_date: Option<NaiveDate>,
75
76    // === Cross-References ===
77    /// Related workpaper IDs
78    pub workpaper_refs: Vec<Uuid>,
79    /// Related evidence IDs
80    pub evidence_refs: Vec<Uuid>,
81
82    // === Status ===
83    pub status: JudgmentStatus,
84    pub created_at: DateTime<Utc>,
85    pub updated_at: DateTime<Utc>,
86}
87
88impl ProfessionalJudgment {
89    /// Create a new professional judgment document.
90    pub fn new(engagement_id: Uuid, judgment_type: JudgmentType, subject: &str) -> Self {
91        let now = Utc::now();
92        Self {
93            judgment_id: Uuid::new_v4(),
94            judgment_ref: format!("JDG-{}-{:03}", now.format("%Y"), 1),
95            engagement_id,
96            judgment_type,
97            subject: subject.into(),
98            applicable_standards: judgment_type.default_standards(),
99            issue_description: String::new(),
100            information_considered: Vec::new(),
101            alternatives_evaluated: Vec::new(),
102            skepticism_applied: SkepticismDocumentation::default(),
103            conclusion: String::new(),
104            rationale: String::new(),
105            residual_risk: String::new(),
106            impact_on_audit: None,
107            consultation_required: judgment_type.typically_requires_consultation(),
108            consultation: None,
109            preparer_id: String::new(),
110            preparer_name: String::new(),
111            preparer_date: now.date_naive(),
112            reviewer_id: None,
113            reviewer_name: None,
114            reviewer_date: None,
115            partner_concurrence_required: judgment_type.requires_partner_concurrence(),
116            partner_concurrence_id: None,
117            partner_concurrence_date: None,
118            workpaper_refs: Vec::new(),
119            evidence_refs: Vec::new(),
120            status: JudgmentStatus::Draft,
121            created_at: now,
122            updated_at: now,
123        }
124    }
125
126    /// Set the issue description.
127    pub fn with_issue(mut self, issue: &str) -> Self {
128        self.issue_description = issue.into();
129        self
130    }
131
132    /// Add information considered.
133    pub fn add_information(&mut self, item: InformationItem) {
134        self.information_considered.push(item);
135        self.updated_at = Utc::now();
136    }
137
138    /// Add an alternative evaluation.
139    pub fn add_alternative(&mut self, alternative: AlternativeEvaluation) {
140        self.alternatives_evaluated.push(alternative);
141        self.updated_at = Utc::now();
142    }
143
144    /// Set skepticism documentation.
145    pub fn with_skepticism(mut self, skepticism: SkepticismDocumentation) -> Self {
146        self.skepticism_applied = skepticism;
147        self
148    }
149
150    /// Set conclusion.
151    pub fn with_conclusion(
152        mut self,
153        conclusion: &str,
154        rationale: &str,
155        residual_risk: &str,
156    ) -> Self {
157        self.conclusion = conclusion.into();
158        self.rationale = rationale.into();
159        self.residual_risk = residual_risk.into();
160        self
161    }
162
163    /// Set preparer.
164    pub fn with_preparer(mut self, id: &str, name: &str, date: NaiveDate) -> Self {
165        self.preparer_id = id.into();
166        self.preparer_name = name.into();
167        self.preparer_date = date;
168        self
169    }
170
171    /// Add reviewer sign-off.
172    pub fn add_review(&mut self, id: &str, name: &str, date: NaiveDate) {
173        self.reviewer_id = Some(id.into());
174        self.reviewer_name = Some(name.into());
175        self.reviewer_date = Some(date);
176        self.status = JudgmentStatus::Reviewed;
177        self.updated_at = Utc::now();
178    }
179
180    /// Add partner concurrence.
181    pub fn add_partner_concurrence(&mut self, id: &str, date: NaiveDate) {
182        self.partner_concurrence_id = Some(id.into());
183        self.partner_concurrence_date = Some(date);
184        self.status = JudgmentStatus::Approved;
185        self.updated_at = Utc::now();
186    }
187
188    /// Add consultation record.
189    pub fn add_consultation(&mut self, consultation: ConsultationRecord) {
190        self.consultation = Some(consultation);
191        self.updated_at = Utc::now();
192    }
193
194    /// Check if judgment is fully approved.
195    pub fn is_approved(&self) -> bool {
196        let reviewer_ok = self.reviewer_id.is_some();
197        let partner_ok =
198            !self.partner_concurrence_required || self.partner_concurrence_id.is_some();
199        let consultation_ok = !self.consultation_required || self.consultation.is_some();
200        reviewer_ok && partner_ok && consultation_ok
201    }
202}
203
204impl ToNodeProperties for ProfessionalJudgment {
205    fn node_type_name(&self) -> &'static str {
206        "professional_judgment"
207    }
208    fn node_type_code(&self) -> u16 {
209        365
210    }
211    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
212        let mut p = HashMap::new();
213        p.insert(
214            "judgmentId".into(),
215            GraphPropertyValue::String(self.judgment_id.to_string()),
216        );
217        p.insert(
218            "judgmentRef".into(),
219            GraphPropertyValue::String(self.judgment_ref.clone()),
220        );
221        p.insert(
222            "engagementId".into(),
223            GraphPropertyValue::String(self.engagement_id.to_string()),
224        );
225        p.insert(
226            "judgmentType".into(),
227            GraphPropertyValue::String(format!("{:?}", self.judgment_type)),
228        );
229        p.insert(
230            "topic".into(),
231            GraphPropertyValue::String(self.subject.clone()),
232        );
233        p.insert(
234            "conclusion".into(),
235            GraphPropertyValue::String(self.conclusion.clone()),
236        );
237        p.insert(
238            "status".into(),
239            GraphPropertyValue::String(format!("{:?}", self.status)),
240        );
241        p.insert(
242            "alternativesCount".into(),
243            GraphPropertyValue::Int(self.alternatives_evaluated.len() as i64),
244        );
245        p.insert(
246            "consultationRequired".into(),
247            GraphPropertyValue::Bool(self.consultation_required),
248        );
249        p.insert(
250            "partnerConcurrenceRequired".into(),
251            GraphPropertyValue::Bool(self.partner_concurrence_required),
252        );
253        p.insert(
254            "isApproved".into(),
255            GraphPropertyValue::Bool(self.is_approved()),
256        );
257        p
258    }
259}
260
261/// Type of professional judgment.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
263#[serde(rename_all = "snake_case")]
264pub enum JudgmentType {
265    /// Materiality determination
266    MaterialityDetermination,
267    /// Risk assessment judgment
268    #[default]
269    RiskAssessment,
270    /// Control evaluation
271    ControlEvaluation,
272    /// Accounting estimate evaluation
273    EstimateEvaluation,
274    /// Going concern assessment
275    GoingConcern,
276    /// Misstatement evaluation
277    MisstatementEvaluation,
278    /// Audit report modification decision
279    ReportingDecision,
280    /// Sampling design judgment
281    SamplingDesign,
282    /// Related party judgment
283    RelatedPartyAssessment,
284    /// Subsequent events evaluation
285    SubsequentEvents,
286    /// Fraud risk assessment
287    FraudRiskAssessment,
288}
289
290impl JudgmentType {
291    /// Get default applicable standards.
292    pub fn default_standards(&self) -> Vec<String> {
293        match self {
294            Self::MaterialityDetermination => vec!["ISA 320".into(), "ISA 450".into()],
295            Self::RiskAssessment => vec!["ISA 315".into()],
296            Self::ControlEvaluation => vec!["ISA 330".into(), "ISA 265".into()],
297            Self::EstimateEvaluation => vec!["ISA 540".into()],
298            Self::GoingConcern => vec!["ISA 570".into()],
299            Self::MisstatementEvaluation => vec!["ISA 450".into()],
300            Self::ReportingDecision => vec!["ISA 700".into(), "ISA 705".into(), "ISA 706".into()],
301            Self::SamplingDesign => vec!["ISA 530".into()],
302            Self::RelatedPartyAssessment => vec!["ISA 550".into()],
303            Self::SubsequentEvents => vec!["ISA 560".into()],
304            Self::FraudRiskAssessment => vec!["ISA 240".into()],
305        }
306    }
307
308    /// Check if this type typically requires consultation.
309    pub fn typically_requires_consultation(&self) -> bool {
310        matches!(
311            self,
312            Self::GoingConcern
313                | Self::ReportingDecision
314                | Self::FraudRiskAssessment
315                | Self::EstimateEvaluation
316        )
317    }
318
319    /// Check if this type requires partner concurrence.
320    pub fn requires_partner_concurrence(&self) -> bool {
321        matches!(
322            self,
323            Self::MaterialityDetermination
324                | Self::GoingConcern
325                | Self::ReportingDecision
326                | Self::FraudRiskAssessment
327        )
328    }
329}
330
331/// Information item considered in judgment.
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct InformationItem {
334    /// Item ID
335    pub item_id: Uuid,
336    /// Description of information
337    pub description: String,
338    /// Source of information
339    pub source: String,
340    /// Reliability assessment
341    pub reliability: InformationReliability,
342    /// Relevance to the judgment
343    pub relevance: String,
344    /// Weight given in analysis
345    pub weight: InformationWeight,
346}
347
348impl InformationItem {
349    /// Create a new information item.
350    pub fn new(
351        description: &str,
352        source: &str,
353        reliability: InformationReliability,
354        relevance: &str,
355    ) -> Self {
356        Self {
357            item_id: Uuid::new_v4(),
358            description: description.into(),
359            source: source.into(),
360            reliability,
361            relevance: relevance.into(),
362            weight: InformationWeight::Moderate,
363        }
364    }
365
366    /// Set the weight.
367    pub fn with_weight(mut self, weight: InformationWeight) -> Self {
368        self.weight = weight;
369        self
370    }
371}
372
373/// Reliability of information.
374#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
375#[serde(rename_all = "snake_case")]
376pub enum InformationReliability {
377    /// High reliability
378    High,
379    /// Medium reliability
380    #[default]
381    Medium,
382    /// Low reliability
383    Low,
384}
385
386/// Weight given to information.
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
388#[serde(rename_all = "snake_case")]
389pub enum InformationWeight {
390    /// High weight - primary factor
391    High,
392    /// Moderate weight
393    #[default]
394    Moderate,
395    /// Low weight - secondary factor
396    Low,
397    /// Not weighted - for context only
398    Context,
399}
400
401/// Alternative evaluation in judgment process.
402#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct AlternativeEvaluation {
404    /// Alternative ID
405    pub alternative_id: Uuid,
406    /// Description of alternative
407    pub description: String,
408    /// Pros/advantages
409    pub pros: Vec<String>,
410    /// Cons/disadvantages
411    pub cons: Vec<String>,
412    /// Risk level if chosen
413    pub risk_level: RiskLevel,
414    /// Was this alternative selected?
415    pub selected: bool,
416    /// Reason if not selected
417    pub rejection_reason: Option<String>,
418}
419
420impl AlternativeEvaluation {
421    /// Create a new alternative evaluation.
422    pub fn new(description: &str, pros: Vec<String>, cons: Vec<String>) -> Self {
423        Self {
424            alternative_id: Uuid::new_v4(),
425            description: description.into(),
426            pros,
427            cons,
428            risk_level: RiskLevel::Medium,
429            selected: false,
430            rejection_reason: None,
431        }
432    }
433
434    /// Mark as selected.
435    pub fn select(mut self) -> Self {
436        self.selected = true;
437        self
438    }
439
440    /// Mark as rejected with reason.
441    pub fn reject(mut self, reason: &str) -> Self {
442        self.selected = false;
443        self.rejection_reason = Some(reason.into());
444        self
445    }
446}
447
448/// Documentation of professional skepticism.
449#[derive(Debug, Clone, Serialize, Deserialize, Default)]
450pub struct SkepticismDocumentation {
451    /// Contradictory evidence considered
452    pub contradictory_evidence_considered: Vec<String>,
453    /// Management bias indicators evaluated
454    pub management_bias_indicators: Vec<String>,
455    /// Alternative explanations explored
456    pub alternative_explanations: Vec<String>,
457    /// Challenging questions asked
458    pub challenging_questions: Vec<String>,
459    /// Corroboration obtained
460    pub corroboration_obtained: String,
461    /// Overall skepticism assessment
462    pub skepticism_assessment: String,
463}
464
465impl SkepticismDocumentation {
466    /// Create skepticism documentation.
467    pub fn new(assessment: &str) -> Self {
468        Self {
469            skepticism_assessment: assessment.into(),
470            ..Default::default()
471        }
472    }
473
474    /// Add contradictory evidence.
475    pub fn with_contradictory_evidence(mut self, evidence: Vec<String>) -> Self {
476        self.contradictory_evidence_considered = evidence;
477        self
478    }
479
480    /// Add management bias indicators.
481    pub fn with_bias_indicators(mut self, indicators: Vec<String>) -> Self {
482        self.management_bias_indicators = indicators;
483        self
484    }
485
486    /// Add alternative explanations.
487    pub fn with_alternatives(mut self, alternatives: Vec<String>) -> Self {
488        self.alternative_explanations = alternatives;
489        self
490    }
491}
492
493/// Consultation record.
494#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct ConsultationRecord {
496    /// Consultation ID
497    pub consultation_id: Uuid,
498    /// Consultant (internal or external)
499    pub consultant: String,
500    /// Consultant title/role
501    pub consultant_role: String,
502    /// Is external consultant?
503    pub is_external: bool,
504    /// Date of consultation
505    pub consultation_date: NaiveDate,
506    /// Issue presented
507    pub issue_presented: String,
508    /// Advice received
509    pub advice_received: String,
510    /// How advice was applied
511    pub advice_application: String,
512    /// Consultation conclusion
513    pub conclusion: String,
514}
515
516impl ConsultationRecord {
517    /// Create a new consultation record.
518    pub fn new(consultant: &str, role: &str, is_external: bool, date: NaiveDate) -> Self {
519        Self {
520            consultation_id: Uuid::new_v4(),
521            consultant: consultant.into(),
522            consultant_role: role.into(),
523            is_external,
524            consultation_date: date,
525            issue_presented: String::new(),
526            advice_received: String::new(),
527            advice_application: String::new(),
528            conclusion: String::new(),
529        }
530    }
531
532    /// Set the issue and advice.
533    pub fn with_content(
534        mut self,
535        issue: &str,
536        advice: &str,
537        application: &str,
538        conclusion: &str,
539    ) -> Self {
540        self.issue_presented = issue.into();
541        self.advice_received = advice.into();
542        self.advice_application = application.into();
543        self.conclusion = conclusion.into();
544        self
545    }
546}
547
548/// Judgment status.
549#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
550#[serde(rename_all = "snake_case")]
551pub enum JudgmentStatus {
552    /// Draft
553    #[default]
554    Draft,
555    /// Pending review
556    PendingReview,
557    /// Reviewed
558    Reviewed,
559    /// Pending consultation
560    PendingConsultation,
561    /// Pending partner concurrence
562    PendingPartnerConcurrence,
563    /// Approved
564    Approved,
565    /// Superseded
566    Superseded,
567}
568
569#[cfg(test)]
570#[allow(clippy::unwrap_used)]
571mod tests {
572    use super::*;
573
574    #[test]
575    fn test_judgment_creation() {
576        let judgment = ProfessionalJudgment::new(
577            Uuid::new_v4(),
578            JudgmentType::MaterialityDetermination,
579            "Overall audit materiality",
580        )
581        .with_issue("Determination of materiality for the 2025 audit")
582        .with_conclusion(
583            "Materiality set at $1M based on 0.5% of revenue",
584            "Revenue is stable metric and primary KPI for stakeholders",
585            "Risk of material misstatement below $1M not individually evaluated",
586        );
587
588        assert_eq!(
589            judgment.judgment_type,
590            JudgmentType::MaterialityDetermination
591        );
592        assert!(judgment.partner_concurrence_required);
593    }
594
595    #[test]
596    fn test_information_item() {
597        let item = InformationItem::new(
598            "Prior year financial statements",
599            "Audited FS",
600            InformationReliability::High,
601            "Baseline for trend analysis",
602        )
603        .with_weight(InformationWeight::High);
604
605        assert_eq!(item.reliability, InformationReliability::High);
606        assert_eq!(item.weight, InformationWeight::High);
607    }
608
609    #[test]
610    fn test_alternative_evaluation() {
611        let selected = AlternativeEvaluation::new(
612            "Use revenue as materiality base",
613            vec!["Stable metric".into(), "Primary KPI".into()],
614            vec!["May not capture asset-focused risks".into()],
615        )
616        .select();
617
618        let rejected = AlternativeEvaluation::new(
619            "Use total assets as materiality base",
620            vec!["Captures balance sheet risks".into()],
621            vec!["Assets less stable".into()],
622        )
623        .reject("Revenue more relevant to stakeholders");
624
625        assert!(selected.selected);
626        assert!(!rejected.selected);
627        assert!(rejected.rejection_reason.is_some());
628    }
629
630    #[test]
631    fn test_judgment_approval() {
632        let mut judgment = ProfessionalJudgment::new(
633            Uuid::new_v4(),
634            JudgmentType::RiskAssessment,
635            "Overall risk assessment",
636        );
637
638        // Not approved initially
639        assert!(!judgment.is_approved());
640
641        // Add reviewer
642        judgment.add_review(
643            "reviewer1",
644            "Senior Manager",
645            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
646        );
647
648        // Risk assessment doesn't require partner concurrence
649        assert!(judgment.is_approved());
650    }
651
652    #[test]
653    fn test_judgment_types() {
654        assert!(JudgmentType::GoingConcern.requires_partner_concurrence());
655        assert!(JudgmentType::GoingConcern.typically_requires_consultation());
656        assert!(!JudgmentType::SamplingDesign.requires_partner_concurrence());
657    }
658}