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