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