Skip to main content

datasynth_generators/audit/
scots_generator.rs

1//! Significant Classes of Transactions (SCOTS) generator per ISA 315.
2//!
3//! Generates the standard set of SCOTs based on the business processes
4//! configured.  Volume and monetary value are derived from actual JE data
5//! (count and aggregate amount by business process / account area).
6//!
7//! # Standard SCOTs generated
8//!
9//! | SCOT name                     | Process | Type       | Significance |
10//! |-------------------------------|---------|------------|--------------|
11//! | Revenue — Product Sales       | O2C     | Routine    | High         |
12//! | Purchases — Procurement       | P2P     | Routine    | High         |
13//! | Payroll                       | H2R     | Routine    | Medium       |
14//! | Fixed Asset Additions         | R2R     | Non-Routine| Medium       |
15//! | Depreciation                  | R2R     | Estimation | Medium       |
16//! | Tax Provision                 | R2R     | Estimation | High         |
17//! | ECL / Bad Debt Provision      | R2R     | Estimation | High         |
18//! | Period-End Adjustments        | R2R     | Non-Routine| Medium       |
19//! | Intercompany Transactions     | IC      | Routine    | High         |
20//!
21//! Intercompany SCOT is only generated when IC is enabled.
22
23use datasynth_core::models::audit::scots::{
24    CriticalPathStage, EstimationComplexity, ProcessingMethod, ScotSignificance,
25    ScotTransactionType, SignificantClassOfTransactions,
26};
27use datasynth_core::models::JournalEntry;
28use datasynth_core::utils::seeded_rng;
29use rand::RngExt;
30use rand_chacha::ChaCha8Rng;
31use rust_decimal::Decimal;
32use rust_decimal_macros::dec;
33
34// ---------------------------------------------------------------------------
35// SCOT specification (static template)
36// ---------------------------------------------------------------------------
37
38/// Internal specification for a standard SCOT template.
39#[derive(Debug, Clone)]
40struct ScotSpec {
41    scot_name: &'static str,
42    business_process: &'static str,
43    significance_level: ScotSignificance,
44    transaction_type: ScotTransactionType,
45    processing_method: ProcessingMethod,
46    /// GL account prefixes used to extract volume/value from JEs.
47    account_prefixes: &'static [&'static str],
48    relevant_assertions: &'static [&'static str],
49    related_account_areas: &'static [&'static str],
50    estimation_complexity: Option<EstimationComplexity>,
51    /// Critical path stages: (name, description, is_automated, control_id_suffix)
52    stages: &'static [(&'static str, &'static str, bool, Option<&'static str>)],
53    /// Whether this SCOT requires IC to be enabled.
54    requires_ic: bool,
55}
56
57/// Standard SCOTS per ISA 315 / typical audit scope.
58static STANDARD_SCOTS: &[ScotSpec] = &[
59    ScotSpec {
60        scot_name: "Revenue — Product Sales",
61        business_process: "O2C",
62        significance_level: ScotSignificance::High,
63        transaction_type: ScotTransactionType::Routine,
64        processing_method: ProcessingMethod::SemiAutomated,
65        account_prefixes: &["4"],
66        relevant_assertions: &["Occurrence", "Accuracy", "Cutoff"],
67        related_account_areas: &["Revenue", "Trade Receivables"],
68        estimation_complexity: None,
69        stages: &[
70            ("Initiation", "Sales order created by customer or internal sales team", false, Some("C001")),
71            ("Recording", "System records SO upon credit check approval and customer master validation", true, Some("C002")),
72            ("Processing", "Automated posting to revenue accounts upon goods delivery confirmation", true, Some("C003")),
73            ("Reporting", "Revenue aggregated into income statement via automated GL summarisation", true, None),
74        ],
75        requires_ic: false,
76    },
77    ScotSpec {
78        scot_name: "Purchases — Procurement",
79        business_process: "P2P",
80        significance_level: ScotSignificance::High,
81        transaction_type: ScotTransactionType::Routine,
82        processing_method: ProcessingMethod::SemiAutomated,
83        account_prefixes: &["5", "6", "2"],
84        relevant_assertions: &["Occurrence", "Completeness", "Accuracy"],
85        related_account_areas: &["Cost of Sales", "Trade Payables", "Inventory"],
86        estimation_complexity: None,
87        stages: &[
88            ("Initiation", "Purchase requisition raised by department, approved per authority matrix", false, Some("C010")),
89            ("Recording", "System generates purchase order from approved requisition", true, Some("C011")),
90            ("Processing", "Three-way match (PO / GR / invoice) with system tolerance checks", true, Some("C012")),
91            ("Reporting", "Accounts payable and cost postings flow to trial balance automatically", true, None),
92        ],
93        requires_ic: false,
94    },
95    ScotSpec {
96        scot_name: "Payroll",
97        business_process: "H2R",
98        significance_level: ScotSignificance::Medium,
99        transaction_type: ScotTransactionType::Routine,
100        processing_method: ProcessingMethod::SemiAutomated,
101        account_prefixes: &["5", "6"],
102        relevant_assertions: &["Occurrence", "Accuracy", "Completeness"],
103        related_account_areas: &["Cost of Sales", "Accruals"],
104        estimation_complexity: None,
105        stages: &[
106            ("Initiation", "HR confirms headcount and compensation data for the period", false, Some("C020")),
107            ("Recording", "Payroll system calculates gross pay, deductions, and net pay per employee", true, Some("C021")),
108            ("Processing", "Payroll journal entries posted to GL; bank file generated for payment", true, Some("C022")),
109            ("Reporting", "Payroll costs aggregated by cost centre into management and financial reports", true, None),
110        ],
111        requires_ic: false,
112    },
113    ScotSpec {
114        scot_name: "Fixed Asset Additions",
115        business_process: "R2R",
116        significance_level: ScotSignificance::Medium,
117        transaction_type: ScotTransactionType::NonRoutine,
118        processing_method: ProcessingMethod::SemiAutomated,
119        account_prefixes: &["1"],
120        relevant_assertions: &["Existence", "Rights & Obligations", "Accuracy"],
121        related_account_areas: &["Fixed Assets"],
122        estimation_complexity: None,
123        stages: &[
124            ("Initiation", "Capital expenditure request raised, approved per capital authorisation policy", false, Some("C030")),
125            ("Recording", "Asset created in fixed asset register with cost, category, and useful life", false, Some("C031")),
126            ("Processing", "Capitalisation journal entry posted; asset available for depreciation", true, None),
127            ("Reporting", "Fixed assets reported on balance sheet net of accumulated depreciation", true, None),
128        ],
129        requires_ic: false,
130    },
131    ScotSpec {
132        scot_name: "Depreciation",
133        business_process: "R2R",
134        significance_level: ScotSignificance::Medium,
135        transaction_type: ScotTransactionType::Estimation,
136        processing_method: ProcessingMethod::FullyAutomated,
137        account_prefixes: &["1", "5", "6"],
138        relevant_assertions: &["Accuracy", "Valuation & Allocation"],
139        related_account_areas: &["Fixed Assets", "Cost of Sales"],
140        estimation_complexity: Some(EstimationComplexity::Simple),
141        stages: &[
142            ("Initiation", "Period-end close triggers automated depreciation run in asset module", true, Some("C040")),
143            ("Recording", "System calculates depreciation per asset based on cost, method, and useful life", true, Some("C041")),
144            ("Processing", "Depreciation journal entry posted to GL (Dr: Dep Expense / Cr: Accum Dep)", true, None),
145            ("Reporting", "Depreciation charge flows to income statement; net book value updated on balance sheet", true, None),
146        ],
147        requires_ic: false,
148    },
149    ScotSpec {
150        scot_name: "Tax Provision",
151        business_process: "R2R",
152        significance_level: ScotSignificance::High,
153        transaction_type: ScotTransactionType::Estimation,
154        processing_method: ProcessingMethod::Manual,
155        account_prefixes: &["3", "2"],
156        relevant_assertions: &["Accuracy", "Valuation & Allocation", "Completeness (Balance)"],
157        related_account_areas: &["Tax", "Equity"],
158        estimation_complexity: Some(EstimationComplexity::Complex),
159        stages: &[
160            ("Initiation", "Tax team prepares provision calculation based on pre-tax income and timing differences", false, Some("C050")),
161            ("Recording", "Current and deferred tax spreadsheet reviewed and approved by tax director", false, Some("C051")),
162            ("Processing", "Manual journal entry posted for current tax payable and deferred tax asset/liability", false, Some("C052")),
163            ("Reporting", "Tax charge reported in income statement; deferred tax balance on balance sheet", true, None),
164        ],
165        requires_ic: false,
166    },
167    ScotSpec {
168        scot_name: "ECL / Bad Debt Provision",
169        business_process: "R2R",
170        significance_level: ScotSignificance::High,
171        transaction_type: ScotTransactionType::Estimation,
172        processing_method: ProcessingMethod::Manual,
173        account_prefixes: &["1"],
174        relevant_assertions: &["Valuation & Allocation", "Completeness (Balance)"],
175        related_account_areas: &["Trade Receivables", "Provisions"],
176        estimation_complexity: Some(EstimationComplexity::Moderate),
177        stages: &[
178            ("Initiation", "Finance team reviews AR aging and customer credit risk at period end", false, Some("C060")),
179            ("Recording", "ECL / provision matrix applied; individual customer assessments for significant debtors", false, Some("C061")),
180            ("Processing", "Provision journal entry posted (Dr: Bad Debt Expense / Cr: Provision for Doubtful Debts)", false, None),
181            ("Reporting", "Net receivables (after provision) reported on balance sheet; bad debt expense in P&L", true, None),
182        ],
183        requires_ic: false,
184    },
185    ScotSpec {
186        scot_name: "Period-End Adjustments",
187        business_process: "R2R",
188        significance_level: ScotSignificance::Medium,
189        transaction_type: ScotTransactionType::NonRoutine,
190        processing_method: ProcessingMethod::Manual,
191        account_prefixes: &["3", "4", "5", "6"],
192        relevant_assertions: &["Accuracy", "Cutoff", "Occurrence"],
193        related_account_areas: &["Accruals", "Revenue", "Cost of Sales"],
194        estimation_complexity: None,
195        stages: &[
196            ("Initiation", "Close checklist triggers accrual and prepayment review at period end", false, None),
197            ("Recording", "Preparer calculates and documents accruals based on invoices received / services incurred", false, Some("C070")),
198            ("Processing", "Manual journal entries posted and reviewed by controller before period close", false, Some("C071")),
199            ("Reporting", "Accruals and prepayments reported in financial statements per cut-off policy", true, None),
200        ],
201        requires_ic: false,
202    },
203    ScotSpec {
204        scot_name: "Intercompany Transactions",
205        business_process: "IC",
206        significance_level: ScotSignificance::High,
207        transaction_type: ScotTransactionType::Routine,
208        processing_method: ProcessingMethod::SemiAutomated,
209        account_prefixes: &["1", "2", "3", "4"],
210        relevant_assertions: &["Occurrence", "Accuracy", "Completeness"],
211        related_account_areas: &["Related Parties", "Revenue", "Cost of Sales"],
212        estimation_complexity: None,
213        stages: &[
214            ("Initiation", "IC transactions initiated by business units per transfer pricing agreements", false, Some("C080")),
215            ("Recording", "IC netting system captures matching transactions across entities", true, Some("C081")),
216            ("Processing", "Automated matching engine reconciles IC balances; unmatched items flagged for resolution", true, Some("C082")),
217            ("Reporting", "IC balances eliminated on consolidation; residual differences reported", true, None),
218        ],
219        requires_ic: true,
220    },
221];
222
223// ---------------------------------------------------------------------------
224// Configuration
225// ---------------------------------------------------------------------------
226
227/// Configuration for the SCOTS generator.
228#[derive(Debug, Clone)]
229pub struct ScotsGeneratorConfig {
230    /// Whether intercompany transactions are in scope (generates IC SCOT).
231    pub intercompany_enabled: bool,
232    /// Minimum synthetic volume for SCOTs when no JE data is available.
233    pub min_volume: usize,
234    /// Maximum synthetic volume.
235    pub max_volume: usize,
236}
237
238impl Default for ScotsGeneratorConfig {
239    fn default() -> Self {
240        Self {
241            intercompany_enabled: false,
242            min_volume: 50,
243            max_volume: 10_000,
244        }
245    }
246}
247
248// ---------------------------------------------------------------------------
249// Generator
250// ---------------------------------------------------------------------------
251
252/// Generator for ISA 315 Significant Classes of Transactions.
253pub struct ScotsGenerator {
254    rng: ChaCha8Rng,
255    config: ScotsGeneratorConfig,
256}
257
258impl ScotsGenerator {
259    /// Create a new generator with default configuration.
260    pub fn new(seed: u64) -> Self {
261        Self {
262            rng: seeded_rng(seed, 0x315A), // discriminator for ISA 315 SCOTS
263            config: ScotsGeneratorConfig::default(),
264        }
265    }
266
267    /// Create a new generator with custom configuration.
268    pub fn with_config(seed: u64, config: ScotsGeneratorConfig) -> Self {
269        Self {
270            rng: seeded_rng(seed, 0x315A),
271            config,
272        }
273    }
274
275    /// Generate standard SCOTs for a single entity.
276    ///
277    /// Volume and monetary values are derived from the supplied JE slice.
278    /// When no JEs are present for a particular SCOT, synthetic estimates are used.
279    pub fn generate_for_entity(
280        &mut self,
281        entity_code: &str,
282        entries: &[JournalEntry],
283    ) -> Vec<SignificantClassOfTransactions> {
284        let mut scots = Vec::new();
285
286        for spec in STANDARD_SCOTS {
287            if spec.requires_ic && !self.config.intercompany_enabled {
288                continue;
289            }
290
291            let scot = self.build_scot(entity_code, spec, entries);
292            scots.push(scot);
293        }
294
295        scots
296    }
297
298    /// Build a single SCOT from a specification and JE data.
299    fn build_scot(
300        &mut self,
301        entity_code: &str,
302        spec: &ScotSpec,
303        entries: &[JournalEntry],
304    ) -> SignificantClassOfTransactions {
305        let (volume, monetary_value) = self.extract_volume_and_value(entity_code, spec, entries);
306
307        let id = format!(
308            "SCOT-{}-{}",
309            entity_code,
310            spec.scot_name
311                .replace([' ', '—', '-', '/'], "_")
312                .to_uppercase(),
313        );
314
315        let critical_path = spec
316            .stages
317            .iter()
318            .map(|(name, desc, is_auto, ctrl_id)| CriticalPathStage {
319                stage_name: name.to_string(),
320                description: desc.to_string(),
321                is_automated: *is_auto,
322                key_control_id: ctrl_id.map(|c| format!("{entity_code}-{c}")),
323            })
324            .collect();
325
326        SignificantClassOfTransactions {
327            id,
328            entity_code: entity_code.to_string(),
329            scot_name: spec.scot_name.to_string(),
330            business_process: spec.business_process.to_string(),
331            significance_level: spec.significance_level,
332            transaction_type: spec.transaction_type,
333            processing_method: spec.processing_method,
334            volume,
335            monetary_value,
336            critical_path,
337            relevant_assertions: spec
338                .relevant_assertions
339                .iter()
340                .map(|s| s.to_string())
341                .collect(),
342            related_account_areas: spec
343                .related_account_areas
344                .iter()
345                .map(|s| s.to_string())
346                .collect(),
347            estimation_complexity: spec.estimation_complexity,
348        }
349    }
350
351    /// Derive volume (count) and monetary value from JEs for the given SCOT.
352    ///
353    /// Matches JE lines by account prefix.  Falls back to synthetic values
354    /// when no matching entries are found.
355    fn extract_volume_and_value(
356        &mut self,
357        entity_code: &str,
358        spec: &ScotSpec,
359        entries: &[JournalEntry],
360    ) -> (usize, Decimal) {
361        // Count JEs and sum their debit amounts for matching accounts
362        let matching_entries: Vec<&JournalEntry> = entries
363            .iter()
364            .filter(|e| e.company_code() == entity_code)
365            .filter(|e| {
366                e.lines.iter().any(|l| {
367                    spec.account_prefixes
368                        .iter()
369                        .any(|&p| l.account_code.starts_with(p))
370                })
371            })
372            .collect();
373
374        if !matching_entries.is_empty() {
375            let volume = matching_entries.len();
376            let value: Decimal = matching_entries
377                .iter()
378                .flat_map(|e| e.lines.iter())
379                .filter(|l| {
380                    spec.account_prefixes
381                        .iter()
382                        .any(|&p| l.account_code.starts_with(p))
383                })
384                .map(|l| l.debit_amount + l.credit_amount)
385                .sum::<Decimal>()
386                / dec!(2); // avoid double-counting debit+credit
387            (volume.max(1), value.max(dec!(1)))
388        } else {
389            // Synthetic fallback
390            let volume = self
391                .rng
392                .random_range(self.config.min_volume..=self.config.max_volume);
393            let avg_txn = Decimal::from(self.rng.random_range(1_000_i64..=50_000_i64));
394            let value = (Decimal::from(volume as i64) * avg_txn).round_dp(0);
395            (volume, value.max(dec!(1)))
396        }
397    }
398}
399
400// ---------------------------------------------------------------------------
401// Tests
402// ---------------------------------------------------------------------------
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn generates_standard_scots_without_ic() {
410        let mut gen = ScotsGenerator::new(42);
411        let scots = gen.generate_for_entity("C001", &[]);
412        // All non-IC SCOTs should be present (8 without IC)
413        assert_eq!(
414            scots.len(),
415            8,
416            "Expected 8 non-IC SCOTs, got {}",
417            scots.len()
418        );
419    }
420
421    #[test]
422    fn ic_scot_added_when_enabled() {
423        let config = ScotsGeneratorConfig {
424            intercompany_enabled: true,
425            ..ScotsGeneratorConfig::default()
426        };
427        let mut gen = ScotsGenerator::with_config(42, config);
428        let scots = gen.generate_for_entity("C001", &[]);
429        assert_eq!(scots.len(), 9, "Expected 9 SCOTs including IC");
430
431        let ic_scot = scots.iter().find(|s| s.business_process == "IC");
432        assert!(
433            ic_scot.is_some(),
434            "IC SCOT should be present when IC is enabled"
435        );
436    }
437
438    #[test]
439    fn estimation_scots_have_complexity() {
440        let mut gen = ScotsGenerator::new(42);
441        let scots = gen.generate_for_entity("C001", &[]);
442
443        let estimation_scots: Vec<_> = scots
444            .iter()
445            .filter(|s| s.transaction_type == ScotTransactionType::Estimation)
446            .collect();
447
448        assert!(!estimation_scots.is_empty(), "Should have estimation SCOTs");
449        for s in &estimation_scots {
450            assert!(
451                s.estimation_complexity.is_some(),
452                "Estimation SCOT '{}' must have estimation_complexity",
453                s.scot_name
454            );
455        }
456    }
457
458    #[test]
459    fn non_estimation_scots_have_no_complexity() {
460        let mut gen = ScotsGenerator::new(42);
461        let scots = gen.generate_for_entity("C001", &[]);
462
463        for s in &scots {
464            if s.transaction_type != ScotTransactionType::Estimation {
465                assert!(
466                    s.estimation_complexity.is_none(),
467                    "Non-estimation SCOT '{}' should not have estimation_complexity",
468                    s.scot_name
469                );
470            }
471        }
472    }
473
474    #[test]
475    fn all_scots_have_four_critical_path_stages() {
476        let mut gen = ScotsGenerator::new(42);
477        let scots = gen.generate_for_entity("C001", &[]);
478
479        for s in &scots {
480            assert_eq!(
481                s.critical_path.len(),
482                4,
483                "SCOT '{}' should have exactly 4 critical path stages",
484                s.scot_name
485            );
486        }
487    }
488
489    #[test]
490    fn scot_ids_are_unique() {
491        let mut gen = ScotsGenerator::new(42);
492        let scots = gen.generate_for_entity("C001", &[]);
493
494        let ids: std::collections::HashSet<&str> = scots.iter().map(|s| s.id.as_str()).collect();
495        assert_eq!(ids.len(), scots.len(), "SCOT IDs should be unique");
496    }
497
498    #[test]
499    fn volume_and_value_are_positive() {
500        let mut gen = ScotsGenerator::new(42);
501        let scots = gen.generate_for_entity("C001", &[]);
502
503        for s in &scots {
504            assert!(s.volume > 0, "SCOT '{}' volume must be > 0", s.scot_name);
505            assert!(
506                s.monetary_value > Decimal::ZERO,
507                "SCOT '{}' monetary_value must be > 0",
508                s.scot_name
509            );
510        }
511    }
512
513    #[test]
514    fn tax_provision_is_high_significance_estimation() {
515        let mut gen = ScotsGenerator::new(42);
516        let scots = gen.generate_for_entity("C001", &[]);
517
518        let tax = scots
519            .iter()
520            .find(|s| s.scot_name == "Tax Provision")
521            .unwrap();
522        assert_eq!(tax.significance_level, ScotSignificance::High);
523        assert_eq!(tax.transaction_type, ScotTransactionType::Estimation);
524        assert_eq!(
525            tax.estimation_complexity,
526            Some(EstimationComplexity::Complex)
527        );
528    }
529
530    #[test]
531    fn revenue_scot_is_o2c_routine_high() {
532        let mut gen = ScotsGenerator::new(42);
533        let scots = gen.generate_for_entity("C001", &[]);
534
535        let rev = scots
536            .iter()
537            .find(|s| s.scot_name == "Revenue — Product Sales")
538            .unwrap();
539        assert_eq!(rev.business_process, "O2C");
540        assert_eq!(rev.transaction_type, ScotTransactionType::Routine);
541        assert_eq!(rev.significance_level, ScotSignificance::High);
542    }
543}