1use 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#[derive(Debug, Clone)]
30pub struct GoingConcernGeneratorConfig {
31 pub clean_probability: f64,
33 pub mild_probability: f64,
35 }
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#[derive(Debug, Clone)]
56pub struct GoingConcernInput {
57 pub entity_code: String,
59 pub net_income: Decimal,
61 pub working_capital: Decimal,
63 pub operating_cash_flow: Decimal,
65 pub total_debt: Decimal,
67 pub assessment_date: NaiveDate,
69}
70
71pub struct GoingConcernGenerator {
77 rng: ChaCha8Rng,
78 config: GoingConcernGeneratorConfig,
79}
80
81impl GoingConcernGenerator {
82 pub fn new(seed: u64) -> Self {
84 Self {
85 rng: seeded_rng(seed, 0x570), config: GoingConcernGeneratorConfig::default(),
87 }
88 }
89
90 pub fn with_config(seed: u64, config: GoingConcernGeneratorConfig) -> Self {
92 Self {
93 rng: seeded_rng(seed, 0x570),
94 config,
95 }
96 }
97
98 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(), 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 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 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 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 if indicators.len() < 3 {
256 let roll: f64 = self.rng.random();
257 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 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 fn random_non_financial_indicator(&mut self, entity_code: &str) -> GoingConcernIndicator {
310 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 fn quantitative_measures(
418 &mut self,
419 indicator_type: GoingConcernIndicatorType,
420 ) -> (Decimal, Decimal) {
421 match indicator_type {
422 GoingConcernIndicatorType::RecurringOperatingLosses => {
423 let loss = Decimal::new(self.rng.random_range(100_000i64..=5_000_000), 0);
425 let threshold = loss * Decimal::new(150, 2); (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 let actual = Decimal::new(self.rng.random_range(350i64..=600), 2); let covenant = Decimal::new(300, 2); (actual, covenant)
443 }
444 GoingConcernIndicatorType::LossOfKeyCustomer => {
445 let pct = Decimal::new(self.rng.random_range(15i64..=40), 2); let threshold = Decimal::new(10, 2); (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#[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 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}