datasynth-core 2.4.0

Core domain models, traits, and distributions for synthetic enterprise data generation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
//! Risk assessment models per ISA 315 and ISA 330.
//!
//! Risk assessment is the foundation of a risk-based audit approach,
//! identifying risks of material misstatement at both the financial
//! statement level and assertion level.

use std::hash::{Hash, Hasher};

use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use super::engagement::RiskLevel;
use super::workpaper::Assertion;

/// Risk lifecycle status — tracks whether the risk is active, mitigated, etc.
///
/// Distinct from [`RiskReviewStatus`] which tracks the *review workflow*
/// (Draft/PendingReview/Approved). `RiskStatus` tracks the *risk lifecycle state*.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RiskStatus {
    /// Risk is active and requires monitoring/mitigation
    #[default]
    Active,
    /// Risk has been mitigated by controls
    Mitigated,
    /// Risk has been accepted (residual risk within tolerance)
    Accepted,
    /// Risk is closed (no longer applicable)
    Closed,
}

/// Derive a continuous score in `[lo, hi]` from a [`RiskLevel`] enum,
/// using deterministic jitter seeded from `risk_id`.
///
/// The jitter is derived by hashing `risk_id` to get a stable fraction in `[0, 1)`,
/// then mapping it into the `[lo, hi]` range for the given level:
///   - Low:         `[0.15, 0.35]`
///   - Medium:      `[0.35, 0.55]`
///   - High:        `[0.55, 0.80]`
///   - Significant: `[0.80, 0.95]`
fn continuous_score(level: &RiskLevel, risk_id: &Uuid, discriminator: u8) -> f64 {
    let (lo, hi) = match level {
        RiskLevel::Low => (0.15, 0.35),
        RiskLevel::Medium => (0.35, 0.55),
        RiskLevel::High => (0.55, 0.80),
        RiskLevel::Significant => (0.80, 0.95),
    };

    // Deterministic jitter: hash risk_id + discriminator
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    risk_id.hash(&mut hasher);
    discriminator.hash(&mut hasher);
    let hash = hasher.finish();

    // Map hash to [0, 1) fraction, then scale to [lo, hi]
    let frac = (hash as f64) / (u64::MAX as f64);
    lo + frac * (hi - lo)
}

/// Risk assessment for an account or process.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RiskAssessment {
    /// Unique risk ID
    pub risk_id: Uuid,
    /// External reference
    pub risk_ref: String,
    /// Engagement ID
    pub engagement_id: Uuid,
    /// Risk category
    pub risk_category: RiskCategory,
    /// Account or process being assessed
    pub account_or_process: String,
    /// Specific assertion if applicable
    pub assertion: Option<Assertion>,
    /// Risk description
    pub description: String,

    // === Risk Assessment ===
    /// Inherent risk assessment
    pub inherent_risk: RiskLevel,
    /// Control risk assessment
    pub control_risk: RiskLevel,
    /// Combined risk of material misstatement
    pub risk_of_material_misstatement: RiskLevel,
    /// Is this a significant risk per ISA 315?
    pub is_significant_risk: bool,
    /// Rationale for significant risk designation
    pub significant_risk_rationale: Option<String>,

    // === Continuous Risk Scores (for heatmap placement) ===
    /// Inherent impact score (0.0-1.0), derived from `inherent_risk` level
    pub inherent_impact: f64,
    /// Inherent likelihood score (0.0-1.0), derived from `inherent_risk` level
    pub inherent_likelihood: f64,
    /// Residual impact score (0.0-1.0), derived from `control_risk` level
    pub residual_impact: f64,
    /// Residual likelihood score (0.0-1.0), derived from `control_risk` level
    pub residual_likelihood: f64,
    /// Composite risk score: `inherent_impact * inherent_likelihood * 100`
    pub risk_score: f64,

    // === Display ===
    /// Human-readable risk name (e.g. "Revenue Recognition Risk [High]")
    pub risk_name: String,

    // === Control Linkage ===
    /// Number of mitigating controls linked to this risk
    pub mitigating_control_count: u32,
    /// Number of effective (passing) controls among mitigating controls
    pub effective_control_count: u32,

    // === Lifecycle Status ===
    /// Risk lifecycle status (Active, Mitigated, Accepted, Closed)
    pub status: RiskStatus,

    // === Fraud Risk ===
    /// Fraud risk factors identified
    pub fraud_risk_factors: Vec<FraudRiskFactor>,
    /// Presumed fraud risk in revenue recognition?
    pub presumed_revenue_fraud_risk: bool,
    /// Presumed management override risk?
    pub presumed_management_override: bool,

    // === Response ===
    /// Planned audit response
    pub planned_response: Vec<PlannedResponse>,
    /// Nature of procedures (substantive, control, combined)
    pub response_nature: ResponseNature,
    /// Extent (sample size considerations)
    pub response_extent: String,
    /// Timing (interim, year-end, subsequent)
    pub response_timing: ResponseTiming,

    // === Assessment Details ===
    /// Assessed by user ID
    pub assessed_by: String,
    /// Assessment date
    pub assessed_date: NaiveDate,
    /// Review status
    pub review_status: RiskReviewStatus,
    /// Reviewer ID
    pub reviewer_id: Option<String>,
    /// Review date
    pub review_date: Option<NaiveDate>,

    // === Cross-References ===
    /// Related workpaper IDs
    pub workpaper_refs: Vec<Uuid>,
    /// Related control IDs
    pub related_controls: Vec<String>,

    #[serde(with = "crate::serde_timestamp::utc")]
    pub created_at: DateTime<Utc>,
    #[serde(with = "crate::serde_timestamp::utc")]
    pub updated_at: DateTime<Utc>,
}

impl RiskAssessment {
    /// Create a new risk assessment.
    pub fn new(
        engagement_id: Uuid,
        risk_category: RiskCategory,
        account_or_process: &str,
        description: &str,
    ) -> Self {
        let now = Utc::now();
        let risk_id = Uuid::new_v4();
        let default_level = RiskLevel::Medium;
        let inherent_impact = continuous_score(&default_level, &risk_id, 0);
        let inherent_likelihood = continuous_score(&default_level, &risk_id, 1);
        let residual_impact = continuous_score(&default_level, &risk_id, 2);
        let residual_likelihood = continuous_score(&default_level, &risk_id, 3);
        let risk_score = inherent_impact * inherent_likelihood * 100.0;
        let risk_name = format!("{} Risk [{:?}]", account_or_process, default_level);

        Self {
            risk_id,
            risk_ref: format!(
                "RISK-{}",
                Uuid::new_v4().simple().to_string()[..8].to_uppercase()
            ),
            engagement_id,
            risk_category,
            account_or_process: account_or_process.into(),
            assertion: None,
            description: description.into(),
            inherent_risk: default_level,
            control_risk: default_level,
            risk_of_material_misstatement: default_level,
            is_significant_risk: false,
            significant_risk_rationale: None,
            inherent_impact,
            inherent_likelihood,
            residual_impact,
            residual_likelihood,
            risk_score,
            risk_name,
            mitigating_control_count: 0,
            effective_control_count: 0,
            status: RiskStatus::Active,
            fraud_risk_factors: Vec::new(),
            presumed_revenue_fraud_risk: false,
            presumed_management_override: true,
            planned_response: Vec::new(),
            response_nature: ResponseNature::Combined,
            response_extent: String::new(),
            response_timing: ResponseTiming::YearEnd,
            assessed_by: String::new(),
            assessed_date: now.date_naive(),
            review_status: RiskReviewStatus::Draft,
            reviewer_id: None,
            review_date: None,
            workpaper_refs: Vec::new(),
            related_controls: Vec::new(),
            created_at: now,
            updated_at: now,
        }
    }

    /// Set the assertion being assessed.
    pub fn with_assertion(mut self, assertion: Assertion) -> Self {
        self.assertion = Some(assertion);
        self
    }

    /// Set risk levels and recompute continuous scores.
    pub fn with_risk_levels(mut self, inherent: RiskLevel, control: RiskLevel) -> Self {
        self.inherent_risk = inherent;
        self.control_risk = control;
        self.risk_of_material_misstatement = self.calculate_romm();
        self.recompute_continuous_scores();
        self
    }

    /// Mark as significant risk.
    pub fn mark_significant(mut self, rationale: &str) -> Self {
        self.is_significant_risk = true;
        self.significant_risk_rationale = Some(rationale.into());
        self
    }

    /// Add a fraud risk factor.
    pub fn add_fraud_factor(&mut self, factor: FraudRiskFactor) {
        self.fraud_risk_factors.push(factor);
        self.updated_at = Utc::now();
    }

    /// Add a planned response.
    pub fn add_response(&mut self, response: PlannedResponse) {
        self.planned_response.push(response);
        self.updated_at = Utc::now();
    }

    /// Set who assessed this risk.
    pub fn with_assessed_by(mut self, user_id: &str, date: NaiveDate) -> Self {
        self.assessed_by = user_id.into();
        self.assessed_date = date;
        self
    }

    /// Calculate risk of material misstatement from IR and CR.
    fn calculate_romm(&self) -> RiskLevel {
        let ir_score = self.inherent_risk.score();
        let cr_score = self.control_risk.score();
        let combined = (ir_score + cr_score) / 2;
        RiskLevel::from_score(combined)
    }

    /// Recompute continuous scores and risk_name from current enum levels.
    fn recompute_continuous_scores(&mut self) {
        self.inherent_impact = continuous_score(&self.inherent_risk, &self.risk_id, 0);
        self.inherent_likelihood = continuous_score(&self.inherent_risk, &self.risk_id, 1);
        self.residual_impact = continuous_score(&self.control_risk, &self.risk_id, 2);
        self.residual_likelihood = continuous_score(&self.control_risk, &self.risk_id, 3);
        self.risk_score = self.inherent_impact * self.inherent_likelihood * 100.0;
        self.risk_name = format!(
            "{} Risk [{:?}]",
            self.account_or_process, self.inherent_risk
        );
    }

    /// Get the detection risk needed to achieve acceptable audit risk.
    pub fn required_detection_risk(&self) -> DetectionRisk {
        match self.risk_of_material_misstatement {
            RiskLevel::Low => DetectionRisk::High,
            RiskLevel::Medium => DetectionRisk::Medium,
            RiskLevel::High | RiskLevel::Significant => DetectionRisk::Low,
        }
    }

    /// Check if this risk requires special audit consideration.
    pub fn requires_special_consideration(&self) -> bool {
        self.is_significant_risk
            || matches!(
                self.risk_of_material_misstatement,
                RiskLevel::High | RiskLevel::Significant
            )
            || !self.fraud_risk_factors.is_empty()
    }
}

/// Risk category per ISA 315.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RiskCategory {
    /// Risk at the financial statement level
    FinancialStatementLevel,
    /// Risk at the assertion level
    #[default]
    AssertionLevel,
    /// Fraud risk
    FraudRisk,
    /// Going concern risk
    GoingConcern,
    /// Related party risk
    RelatedParty,
    /// Accounting estimate risk
    EstimateRisk,
    /// IT general control risk
    ItGeneralControl,
    /// Regulatory compliance risk
    RegulatoryCompliance,
}

/// Fraud risk factor per the fraud triangle.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FraudRiskFactor {
    /// Factor ID
    pub factor_id: Uuid,
    /// Element of fraud triangle
    pub factor_type: FraudTriangleElement,
    /// Specific indicator description
    pub indicator: String,
    /// Risk score (0-100)
    pub score: u8,
    /// Trend direction
    pub trend: Trend,
    /// Source of information
    pub source: String,
    /// Date identified
    pub identified_date: NaiveDate,
}

impl FraudRiskFactor {
    /// Create a new fraud risk factor.
    pub fn new(
        factor_type: FraudTriangleElement,
        indicator: &str,
        score: u8,
        source: &str,
    ) -> Self {
        Self {
            factor_id: Uuid::new_v4(),
            factor_type,
            indicator: indicator.into(),
            score: score.min(100),
            trend: Trend::Stable,
            source: source.into(),
            identified_date: Utc::now().date_naive(),
        }
    }

    /// Set the trend.
    pub fn with_trend(mut self, trend: Trend) -> Self {
        self.trend = trend;
        self
    }
}

/// Elements of the fraud triangle.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FraudTriangleElement {
    /// Opportunity to commit fraud
    Opportunity,
    /// Incentive/pressure to commit fraud
    Pressure,
    /// Rationalization/attitude
    Rationalization,
}

impl FraudTriangleElement {
    /// Get a description.
    pub fn description(&self) -> &'static str {
        match self {
            Self::Opportunity => "Circumstances providing opportunity to commit fraud",
            Self::Pressure => "Incentives or pressures to commit fraud",
            Self::Rationalization => "Attitude or rationalization to justify fraud",
        }
    }
}

/// Trend direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum Trend {
    /// Increasing
    Increasing,
    /// Stable
    #[default]
    Stable,
    /// Decreasing
    Decreasing,
}

/// Planned audit response to identified risk.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlannedResponse {
    /// Response ID
    pub response_id: Uuid,
    /// Procedure description
    pub procedure: String,
    /// Procedure type
    pub procedure_type: ResponseProcedureType,
    /// Assertion addressed
    pub assertion_addressed: Assertion,
    /// Assigned to user ID
    pub assigned_to: String,
    /// Target completion date
    pub target_date: NaiveDate,
    /// Status
    pub status: ResponseStatus,
    /// Workpaper reference when complete
    pub workpaper_ref: Option<Uuid>,
}

impl PlannedResponse {
    /// Create a new planned response.
    pub fn new(
        procedure: &str,
        procedure_type: ResponseProcedureType,
        assertion: Assertion,
        assigned_to: &str,
        target_date: NaiveDate,
    ) -> Self {
        Self {
            response_id: Uuid::new_v4(),
            procedure: procedure.into(),
            procedure_type,
            assertion_addressed: assertion,
            assigned_to: assigned_to.into(),
            target_date,
            status: ResponseStatus::Planned,
            workpaper_ref: None,
        }
    }
}

/// Type of response procedure.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ResponseProcedureType {
    /// Test of controls
    TestOfControls,
    /// Substantive analytical procedure
    AnalyticalProcedure,
    /// Substantive test of details
    #[default]
    TestOfDetails,
    /// External confirmation
    Confirmation,
    /// Physical inspection
    PhysicalInspection,
    /// Inquiry
    Inquiry,
}

/// Nature of audit response.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ResponseNature {
    /// Substantive procedures only
    SubstantiveOnly,
    /// Controls reliance with reduced substantive
    ControlsReliance,
    /// Combined approach
    #[default]
    Combined,
}

/// Timing of audit response.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ResponseTiming {
    /// Interim testing
    Interim,
    /// Year-end testing
    #[default]
    YearEnd,
    /// Roll-forward from interim
    RollForward,
    /// Subsequent events testing
    Subsequent,
}

/// Status of planned response.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ResponseStatus {
    /// Planned but not started
    #[default]
    Planned,
    /// In progress
    InProgress,
    /// Complete
    Complete,
    /// Deferred
    Deferred,
    /// Not required
    NotRequired,
}

/// Risk review status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RiskReviewStatus {
    /// Draft assessment
    #[default]
    Draft,
    /// Pending review
    PendingReview,
    /// Reviewed and approved
    Approved,
    /// Requires revision
    RequiresRevision,
}

/// Detection risk level (inverse of ROMM for achieving acceptable AR).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DetectionRisk {
    /// Can accept high detection risk (less testing)
    High,
    /// Medium detection risk
    Medium,
    /// Low detection risk required (more testing)
    Low,
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn test_risk_assessment_creation() {
        let risk = RiskAssessment::new(
            Uuid::new_v4(),
            RiskCategory::AssertionLevel,
            "Revenue",
            "Risk of fictitious revenue recognition",
        )
        .with_assertion(Assertion::Occurrence)
        .with_risk_levels(RiskLevel::High, RiskLevel::Medium);

        assert!(risk.inherent_risk == RiskLevel::High);
        assert!(
            risk.requires_special_consideration()
                || risk.risk_of_material_misstatement != RiskLevel::Low
        );
    }

    #[test]
    fn test_significant_risk() {
        let risk = RiskAssessment::new(
            Uuid::new_v4(),
            RiskCategory::FraudRisk,
            "Revenue",
            "Fraud risk in revenue recognition",
        )
        .mark_significant("Presumed fraud risk per ISA 240");

        assert!(risk.is_significant_risk);
        assert!(risk.requires_special_consideration());
    }

    #[test]
    fn test_fraud_risk_factor() {
        let factor = FraudRiskFactor::new(
            FraudTriangleElement::Pressure,
            "Management bonus tied to revenue targets",
            75,
            "Bonus plan review",
        )
        .with_trend(Trend::Increasing);

        assert_eq!(factor.factor_type, FraudTriangleElement::Pressure);
        assert_eq!(factor.score, 75);
    }

    #[test]
    fn test_detection_risk() {
        let risk = RiskAssessment::new(
            Uuid::new_v4(),
            RiskCategory::AssertionLevel,
            "Cash",
            "Low risk account",
        )
        .with_risk_levels(RiskLevel::Low, RiskLevel::Low);

        assert_eq!(risk.required_detection_risk(), DetectionRisk::High);
    }
}