Skip to main content

datasynth_generators/audit/
going_concern_generator.rs

1//! Going concern assessment generator — ISA 570 / ASC 205-40.
2//!
3//! Generates one `GoingConcernAssessment` per entity per period.
4//!
5//! # Distribution of outcomes
6//!
7//! | Scenario              | Probability | Indicators | Conclusion                    |
8//! |-----------------------|-------------|------------|-------------------------------|
9//! | Clean (no issues)     | 90–95%      | 0          | `NoMaterialUncertainty`       |
10//! | Mild concerns         | 4–8%        | 1–2        | `MaterialUncertaintyExists`   |
11//! | Significant concerns  | 1–2%        | 3+         | `GoingConcernDoubt`           |
12
13use chrono::NaiveDate;
14use datasynth_core::models::audit::going_concern::{
15    GoingConcernAssessment, GoingConcernIndicator, GoingConcernIndicatorType, GoingConcernSeverity,
16};
17use datasynth_core::utils::seeded_rng;
18use rand::Rng;
19use rand_chacha::ChaCha8Rng;
20use rust_decimal::Decimal;
21use rust_decimal_macros::dec;
22use tracing::info;
23
24// ---------------------------------------------------------------------------
25// Configuration
26// ---------------------------------------------------------------------------
27
28/// Configuration for the going concern generator.
29#[derive(Debug, Clone)]
30pub struct GoingConcernGeneratorConfig {
31    /// Probability that an entity receives zero indicators (clean assessment).
32    pub clean_probability: f64,
33    /// Probability that an entity receives 1–2 indicators (mild concerns).
34    pub mild_probability: f64,
35    // Note: severe probability = 1 - clean_probability - mild_probability.
36}
37
38impl Default for GoingConcernGeneratorConfig {
39    fn default() -> Self {
40        Self {
41            clean_probability: 0.90,
42            mild_probability: 0.08,
43        }
44    }
45}
46
47// ---------------------------------------------------------------------------
48// Financial input for data-driven assessments
49// ---------------------------------------------------------------------------
50
51/// Financial metrics derived from actual generated data, used to derive
52/// going concern indicators from real financials rather than random draws.
53///
54/// All amounts are in the entity's reporting currency.
55#[derive(Debug, Clone)]
56pub struct GoingConcernInput {
57    /// Entity code being assessed.
58    pub entity_code: String,
59    /// Net income / (loss) for the period (negative = loss).
60    pub net_income: Decimal,
61    /// Working capital = current assets − current liabilities (negative = deficiency).
62    pub working_capital: Decimal,
63    /// Net cash from operating activities (negative = outflow).
64    pub operating_cash_flow: Decimal,
65    /// Total financial debt outstanding.
66    pub total_debt: Decimal,
67    /// Date the assessment is finalised.
68    pub assessment_date: NaiveDate,
69}
70
71// ---------------------------------------------------------------------------
72// Generator
73// ---------------------------------------------------------------------------
74
75/// Generator for ISA 570 / ASC 205-40 going concern assessments.
76pub struct GoingConcernGenerator {
77    rng: ChaCha8Rng,
78    config: GoingConcernGeneratorConfig,
79}
80
81impl GoingConcernGenerator {
82    /// Create a new generator with the given seed and default configuration.
83    pub fn new(seed: u64) -> Self {
84        Self {
85            rng: seeded_rng(seed, 0x570), // discriminator for ISA 570
86            config: GoingConcernGeneratorConfig::default(),
87        }
88    }
89
90    /// Create a new generator with custom configuration.
91    pub fn with_config(seed: u64, config: GoingConcernGeneratorConfig) -> Self {
92        Self {
93            rng: seeded_rng(seed, 0x570),
94            config,
95        }
96    }
97
98    /// Generate a going concern assessment for a single entity.
99    ///
100    /// # Arguments
101    /// * `entity_code` — Entity code being assessed.
102    /// * `assessment_date` — Date the assessment was finalised (typically the
103    ///   financial statement approval date).
104    /// * `period` — Human-readable period descriptor (e.g. "FY2024").
105    pub fn generate_for_entity(
106        &mut self,
107        entity_code: &str,
108        assessment_date: NaiveDate,
109        period: &str,
110    ) -> GoingConcernAssessment {
111        info!(
112            "Generating going concern assessment for entity {} period {}",
113            entity_code, period
114        );
115        let roll: f64 = self.rng.random();
116        let indicator_count = if roll < self.config.clean_probability {
117            0
118        } else if roll < self.config.clean_probability + self.config.mild_probability {
119            self.rng.random_range(1u32..=2)
120        } else {
121            self.rng.random_range(3u32..=5)
122        };
123
124        let indicators = (0..indicator_count)
125            .map(|_| self.random_indicator(entity_code))
126            .collect::<Vec<_>>();
127
128        let management_plans = if indicators.is_empty() {
129            Vec::new()
130        } else {
131            self.management_plans(indicators.len())
132        };
133
134        let assessment = GoingConcernAssessment {
135            entity_code: entity_code.to_string(),
136            assessment_date,
137            assessment_period: period.to_string(),
138            indicators,
139            management_plans,
140            auditor_conclusion: Default::default(), // will be overwritten below
141            material_uncertainty_exists: false,
142        }
143        .conclude_from_indicators();
144        info!(
145            "Going concern for {}: {} indicators, conclusion={:?}",
146            entity_code,
147            assessment.indicators.len(),
148            assessment.auditor_conclusion
149        );
150        assessment
151    }
152
153    /// Generate assessments for multiple entities in a single batch.
154    pub fn generate_for_entities(
155        &mut self,
156        entity_codes: &[String],
157        assessment_date: NaiveDate,
158        period: &str,
159    ) -> Vec<GoingConcernAssessment> {
160        entity_codes
161            .iter()
162            .map(|code| self.generate_for_entity(code, assessment_date, period))
163            .collect()
164    }
165
166    /// Generate a going concern assessment driven by actual financial data.
167    ///
168    /// Financial indicators (recurring losses, negative working capital, negative
169    /// operating cash flow) are determined from the supplied [`GoingConcernInput`].
170    /// Non-financial indicators (litigation, regulatory action, etc.) retain the
171    /// random element since they cannot be inferred from journal entries alone.
172    ///
173    /// # Indicator mapping
174    /// - `net_income < 0`          → [`GoingConcernIndicatorType::RecurringOperatingLosses`]
175    /// - `working_capital < 0`     → [`GoingConcernIndicatorType::WorkingCapitalDeficiency`]
176    /// - `operating_cash_flow < 0` → [`GoingConcernIndicatorType::NegativeOperatingCashFlow`]
177    ///
178    /// # Conclusion
179    /// 0 indicators → `NoMaterialUncertainty`, 1–2 → `MaterialUncertaintyExists`,
180    /// 3+ → `GoingConcernDoubt` (same rule as [`generate_for_entity`]).
181    pub fn generate_for_entity_with_input(
182        &mut self,
183        input: &GoingConcernInput,
184        period: &str,
185    ) -> GoingConcernAssessment {
186        let entity_code = input.entity_code.as_str();
187        let mut indicators: Vec<GoingConcernIndicator> = Vec::new();
188
189        // ---- Financial indicators derived from actual data --------------------
190
191        if input.net_income < Decimal::ZERO {
192            let loss = input.net_income.abs();
193            let threshold = loss * dec!(1.50);
194            indicators.push(GoingConcernIndicator {
195                indicator_type: GoingConcernIndicatorType::RecurringOperatingLosses,
196                severity: if loss > Decimal::from(1_000_000i64) {
197                    GoingConcernSeverity::High
198                } else if loss > Decimal::from(100_000i64) {
199                    GoingConcernSeverity::Medium
200                } else {
201                    GoingConcernSeverity::Low
202                },
203                description: self.describe_indicator(
204                    GoingConcernIndicatorType::RecurringOperatingLosses,
205                    entity_code,
206                ),
207                quantitative_measure: Some(loss),
208                threshold: Some(threshold),
209            });
210        }
211
212        if input.working_capital < Decimal::ZERO {
213            let deficit = input.working_capital.abs();
214            indicators.push(GoingConcernIndicator {
215                indicator_type: GoingConcernIndicatorType::WorkingCapitalDeficiency,
216                severity: if deficit > Decimal::from(5_000_000i64) {
217                    GoingConcernSeverity::High
218                } else if deficit > Decimal::from(500_000i64) {
219                    GoingConcernSeverity::Medium
220                } else {
221                    GoingConcernSeverity::Low
222                },
223                description: self.describe_indicator(
224                    GoingConcernIndicatorType::WorkingCapitalDeficiency,
225                    entity_code,
226                ),
227                quantitative_measure: Some(deficit),
228                threshold: Some(Decimal::ZERO),
229            });
230        }
231
232        if input.operating_cash_flow < Decimal::ZERO {
233            let outflow = input.operating_cash_flow.abs();
234            indicators.push(GoingConcernIndicator {
235                indicator_type: GoingConcernIndicatorType::NegativeOperatingCashFlow,
236                severity: if outflow > Decimal::from(2_000_000i64) {
237                    GoingConcernSeverity::High
238                } else if outflow > Decimal::from(200_000i64) {
239                    GoingConcernSeverity::Medium
240                } else {
241                    GoingConcernSeverity::Low
242                },
243                description: self.describe_indicator(
244                    GoingConcernIndicatorType::NegativeOperatingCashFlow,
245                    entity_code,
246                ),
247                quantitative_measure: Some(outflow),
248                threshold: Some(Decimal::ZERO),
249            });
250        }
251
252        // ---- Random non-financial indicators (litigation, regulatory, etc.) --
253        // Only add if the financial indicators haven't already pushed us into
254        // going-concern doubt territory, to keep the realistic distribution.
255        if indicators.len() < 3 {
256            let roll: f64 = self.rng.random();
257            // ~5% chance of a random non-financial indicator when finances are OK
258            if roll < 0.05 {
259                let extra = self.random_non_financial_indicator(entity_code);
260                indicators.push(extra);
261            }
262        }
263
264        let management_plans = if indicators.is_empty() {
265            Vec::new()
266        } else {
267            self.management_plans(indicators.len())
268        };
269
270        GoingConcernAssessment {
271            entity_code: entity_code.to_string(),
272            assessment_date: input.assessment_date,
273            assessment_period: period.to_string(),
274            indicators,
275            management_plans,
276            auditor_conclusion: Default::default(),
277            material_uncertainty_exists: false,
278        }
279        .conclude_from_indicators()
280    }
281
282    /// Generate assessments for multiple entities using financial data inputs.
283    ///
284    /// Entities without a corresponding input fall back to random behaviour.
285    pub fn generate_for_entities_with_inputs(
286        &mut self,
287        entity_codes: &[String],
288        inputs: &[GoingConcernInput],
289        assessment_date: NaiveDate,
290        period: &str,
291    ) -> Vec<GoingConcernAssessment> {
292        entity_codes
293            .iter()
294            .map(|code| {
295                if let Some(input) = inputs.iter().find(|i| &i.entity_code == code) {
296                    self.generate_for_entity_with_input(input, period)
297                } else {
298                    self.generate_for_entity(code, assessment_date, period)
299                }
300            })
301            .collect()
302    }
303
304    // -----------------------------------------------------------------------
305    // Private helpers
306    // -----------------------------------------------------------------------
307
308    /// Generate a random non-financial indicator (litigation, regulatory, etc.).
309    fn random_non_financial_indicator(&mut self, entity_code: &str) -> GoingConcernIndicator {
310        // Only pick from non-financial types
311        let indicator_type = match self.rng.random_range(0u8..5) {
312            0 => GoingConcernIndicatorType::DebtCovenantBreach,
313            1 => GoingConcernIndicatorType::LossOfKeyCustomer,
314            2 => GoingConcernIndicatorType::RegulatoryAction,
315            3 => GoingConcernIndicatorType::LitigationExposure,
316            _ => GoingConcernIndicatorType::InabilityToObtainFinancing,
317        };
318        let severity = self.random_severity();
319        let description = self.describe_indicator(indicator_type, entity_code);
320        let (measure, threshold) = self.quantitative_measures(indicator_type);
321        GoingConcernIndicator {
322            indicator_type,
323            severity,
324            description,
325            quantitative_measure: Some(measure),
326            threshold: Some(threshold),
327        }
328    }
329
330    fn random_indicator(&mut self, entity_code: &str) -> GoingConcernIndicator {
331        let indicator_type = self.random_indicator_type();
332        let severity = self.random_severity();
333
334        let description = self.describe_indicator(indicator_type, entity_code);
335        let (measure, threshold) = self.quantitative_measures(indicator_type);
336
337        GoingConcernIndicator {
338            indicator_type,
339            severity,
340            description,
341            quantitative_measure: Some(measure),
342            threshold: Some(threshold),
343        }
344    }
345
346    fn random_indicator_type(&mut self) -> GoingConcernIndicatorType {
347        match self.rng.random_range(0u8..8) {
348            0 => GoingConcernIndicatorType::RecurringOperatingLosses,
349            1 => GoingConcernIndicatorType::NegativeOperatingCashFlow,
350            2 => GoingConcernIndicatorType::WorkingCapitalDeficiency,
351            3 => GoingConcernIndicatorType::DebtCovenantBreach,
352            4 => GoingConcernIndicatorType::LossOfKeyCustomer,
353            5 => GoingConcernIndicatorType::RegulatoryAction,
354            6 => GoingConcernIndicatorType::LitigationExposure,
355            _ => GoingConcernIndicatorType::InabilityToObtainFinancing,
356        }
357    }
358
359    fn random_severity(&mut self) -> GoingConcernSeverity {
360        match self.rng.random_range(0u8..3) {
361            0 => GoingConcernSeverity::Low,
362            1 => GoingConcernSeverity::Medium,
363            _ => GoingConcernSeverity::High,
364        }
365    }
366
367    fn describe_indicator(
368        &self,
369        indicator_type: GoingConcernIndicatorType,
370        entity_code: &str,
371    ) -> String {
372        match indicator_type {
373            GoingConcernIndicatorType::RecurringOperatingLosses => format!(
374                "{} has reported operating losses in each of the past three financial years, \
375                 indicating structural challenges in its core business model.",
376                entity_code
377            ),
378            GoingConcernIndicatorType::NegativeOperatingCashFlow => format!(
379                "{} generated negative operating cash flows during the current period, \
380                 requiring reliance on financing activities to fund operations.",
381                entity_code
382            ),
383            GoingConcernIndicatorType::WorkingCapitalDeficiency => format!(
384                "{} has a working capital deficiency, with current liabilities exceeding \
385                 current assets, potentially impairing its ability to meet short-term obligations.",
386                entity_code
387            ),
388            GoingConcernIndicatorType::DebtCovenantBreach => format!(
389                "{} has breached one or more financial covenants in its debt agreements, \
390                 which may result in lenders demanding immediate repayment.",
391                entity_code
392            ),
393            GoingConcernIndicatorType::LossOfKeyCustomer => format!(
394                "{} lost a major customer during the period, representing a material decline \
395                 in projected revenue and profitability.",
396                entity_code
397            ),
398            GoingConcernIndicatorType::RegulatoryAction => format!(
399                "{} is subject to regulatory action or investigation that may threaten \
400                 its licence to operate or result in material financial penalties.",
401                entity_code
402            ),
403            GoingConcernIndicatorType::LitigationExposure => format!(
404                "{} faces pending legal proceedings with a potential financial exposure \
405                 that could be material relative to its net assets.",
406                entity_code
407            ),
408            GoingConcernIndicatorType::InabilityToObtainFinancing => format!(
409                "{} has been unable to secure new credit facilities or roll over existing \
410                 financing arrangements, creating a liquidity risk.",
411                entity_code
412            ),
413        }
414    }
415
416    /// Return (quantitative_measure, threshold) for the given indicator type.
417    fn quantitative_measures(
418        &mut self,
419        indicator_type: GoingConcernIndicatorType,
420    ) -> (Decimal, Decimal) {
421        match indicator_type {
422            GoingConcernIndicatorType::RecurringOperatingLosses => {
423                // Loss amount and a materiality threshold
424                let loss = Decimal::new(self.rng.random_range(100_000i64..=5_000_000), 0);
425                let threshold = loss * Decimal::new(150, 2); // 1.5x — significant if > threshold
426                (loss, threshold)
427            }
428            GoingConcernIndicatorType::NegativeOperatingCashFlow => {
429                let outflow = Decimal::new(self.rng.random_range(50_000i64..=2_000_000), 0);
430                let threshold = Decimal::ZERO;
431                (outflow, threshold)
432            }
433            GoingConcernIndicatorType::WorkingCapitalDeficiency => {
434                let deficit = Decimal::new(self.rng.random_range(100_000i64..=10_000_000), 0);
435                let threshold = Decimal::ZERO;
436                (deficit, threshold)
437            }
438            GoingConcernIndicatorType::DebtCovenantBreach => {
439                // Actual leverage ratio vs covenant limit
440                let actual = Decimal::new(self.rng.random_range(350i64..=600), 2); // 3.50–6.00x
441                let covenant = Decimal::new(300, 2); // 3.00x limit
442                (actual, covenant)
443            }
444            GoingConcernIndicatorType::LossOfKeyCustomer => {
445                // Revenue lost as a percentage of total revenue
446                let pct = Decimal::new(self.rng.random_range(15i64..=40), 2); // 15–40%
447                let threshold = Decimal::new(10, 2); // 10% materiality
448                (pct, threshold)
449            }
450            GoingConcernIndicatorType::RegulatoryAction
451            | GoingConcernIndicatorType::LitigationExposure
452            | GoingConcernIndicatorType::InabilityToObtainFinancing => {
453                let exposure = Decimal::new(self.rng.random_range(500_000i64..=20_000_000), 0);
454                let threshold = Decimal::new(self.rng.random_range(1_000_000i64..=5_000_000), 0);
455                (exposure, threshold)
456            }
457        }
458    }
459
460    fn management_plans(&mut self, indicator_count: usize) -> Vec<String> {
461        let all_plans = [
462            "Management has engaged external financial advisors to explore refinancing options \
463             and extend the maturity of existing credit facilities.",
464            "A detailed cash flow management plan has been approved by the board, including \
465             targeted working capital improvements and deferral of non-essential capital expenditure.",
466            "Management is actively pursuing new customer acquisition initiatives and has \
467             secured letters of intent from prospective strategic customers.",
468            "The board has committed to a capital injection of additional equity through \
469             a rights issue to be completed within 90 days of the balance sheet date.",
470            "Management is in advanced negotiations with existing lenders to obtain covenant \
471             waivers and to restructure the terms of outstanding debt facilities.",
472            "A formal cost reduction programme has been announced, targeting annualised \
473             savings sufficient to return the entity to operating profitability within 12 months.",
474            "The entity has received a legally binding letter of support from its parent \
475             company confirming financial support for a minimum of 12 months.",
476        ];
477
478        let n_plans = indicator_count.clamp(1, 3);
479        let start = self
480            .rng
481            .random_range(0..all_plans.len().saturating_sub(n_plans));
482        all_plans[start..start + n_plans]
483            .iter()
484            .map(|s| s.to_string())
485            .collect()
486    }
487}
488
489// ---------------------------------------------------------------------------
490// Unit tests
491// ---------------------------------------------------------------------------
492
493#[cfg(test)]
494#[allow(clippy::unwrap_used)]
495mod tests {
496    use super::*;
497    use datasynth_core::models::audit::going_concern::GoingConcernConclusion;
498
499    fn assessment_date() -> NaiveDate {
500        NaiveDate::from_ymd_opt(2025, 3, 15).unwrap()
501    }
502
503    #[test]
504    fn test_generates_one_assessment_per_entity() {
505        let entities = vec!["C001".to_string(), "C002".to_string(), "C003".to_string()];
506        let mut gen = GoingConcernGenerator::new(42);
507        let assessments = gen.generate_for_entities(&entities, assessment_date(), "FY2024");
508        assert_eq!(assessments.len(), entities.len());
509    }
510
511    #[test]
512    fn test_approximately_90_percent_clean() {
513        let mut total = 0usize;
514        let mut clean = 0usize;
515        for seed in 0..200u64 {
516            let mut gen = GoingConcernGenerator::new(seed);
517            let a = gen.generate_for_entity("C001", assessment_date(), "FY2024");
518            total += 1;
519            if matches!(
520                a.auditor_conclusion,
521                GoingConcernConclusion::NoMaterialUncertainty
522            ) {
523                clean += 1;
524            }
525        }
526        let ratio = clean as f64 / total as f64;
527        assert!(
528            ratio >= 0.80 && ratio <= 0.98,
529            "Clean ratio = {:.2}, expected ~0.90",
530            ratio
531        );
532    }
533
534    #[test]
535    fn test_conclusion_matches_indicator_count() {
536        let mut gen = GoingConcernGenerator::new(42);
537        for seed in 0..100u64 {
538            let mut g = GoingConcernGenerator::new(seed);
539            let a = g.generate_for_entity("C001", assessment_date(), "FY2024");
540            let n = a.indicators.len();
541            match a.auditor_conclusion {
542                GoingConcernConclusion::NoMaterialUncertainty => {
543                    assert_eq!(n, 0, "seed={}: clean but has {} indicators", seed, n);
544                }
545                GoingConcernConclusion::MaterialUncertaintyExists => {
546                    assert!(
547                        n >= 1 && n <= 2,
548                        "seed={}: MaterialUncertainty but {} indicators",
549                        seed,
550                        n
551                    );
552                }
553                GoingConcernConclusion::GoingConcernDoubt => {
554                    assert!(n >= 3, "seed={}: Doubt but only {} indicators", seed, n);
555                }
556            }
557        }
558        drop(gen);
559    }
560
561    #[test]
562    fn test_indicators_have_severity() {
563        for seed in 0..50u64 {
564            let mut gen = GoingConcernGenerator::new(seed);
565            let a = gen.generate_for_entity("C001", assessment_date(), "FY2024");
566            for indicator in &a.indicators {
567                // Severity must be one of the valid variants (always true for an enum,
568                // but this also exercises the serialisation round-trip)
569                let json = serde_json::to_string(&indicator.severity).unwrap();
570                assert!(!json.is_empty());
571            }
572        }
573    }
574
575    #[test]
576    fn test_material_uncertainty_flag_consistent() {
577        for seed in 0..100u64 {
578            let mut gen = GoingConcernGenerator::new(seed);
579            let a = gen.generate_for_entity("C001", assessment_date(), "FY2024");
580            if a.indicators.is_empty() {
581                assert!(
582                    !a.material_uncertainty_exists,
583                    "seed={}: no indicators but material_uncertainty_exists=true",
584                    seed
585                );
586            } else {
587                assert!(
588                    a.material_uncertainty_exists,
589                    "seed={}: has {} indicators but material_uncertainty_exists=false",
590                    seed,
591                    a.indicators.len()
592                );
593            }
594        }
595    }
596
597    #[test]
598    fn test_management_plans_when_indicators_present() {
599        for seed in 0..200u64 {
600            let mut gen = GoingConcernGenerator::new(seed);
601            let a = gen.generate_for_entity("C001", assessment_date(), "FY2024");
602            if !a.indicators.is_empty() {
603                assert!(
604                    !a.management_plans.is_empty(),
605                    "seed={}: indicators present but no management plans",
606                    seed
607                );
608            } else {
609                assert!(
610                    a.management_plans.is_empty(),
611                    "seed={}: no indicators but management plans present",
612                    seed
613                );
614            }
615        }
616    }
617}