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