Skip to main content

datasynth_generators/audit/
confirmation_generator.rs

1//! Confirmation generator for audit engagements.
2//!
3//! Generates external confirmations and their responses per ISA 505.
4//! Supports bank balance, accounts receivable, accounts payable, and
5//! other confirmation types with realistic response distributions.
6
7use chrono::Duration;
8use datasynth_core::utils::seeded_rng;
9use rand::Rng;
10use rand_chacha::ChaCha8Rng;
11use rust_decimal::Decimal;
12
13use datasynth_core::models::audit::{
14    AuditEngagement, ConfirmationResponse, ConfirmationStatus, ConfirmationType,
15    ExternalConfirmation, RecipientType, ResponseType, Workpaper, WorkpaperSection,
16};
17
18/// Configuration for external confirmation generation (ISA 505).
19#[derive(Debug, Clone)]
20pub struct ConfirmationGeneratorConfig {
21    /// Number of confirmations per engagement (min, max)
22    pub confirmations_per_engagement: (u32, u32),
23    /// Fraction of confirmations that are bank balance type
24    pub bank_balance_ratio: f64,
25    /// Fraction of confirmations that are accounts receivable type
26    pub accounts_receivable_ratio: f64,
27    /// Fraction of confirmations that receive a clean "Confirmed" response
28    pub confirmed_response_ratio: f64,
29    /// Fraction of confirmations that receive a "ConfirmedWithException" response
30    pub exception_response_ratio: f64,
31    /// Fraction of confirmations that receive no reply
32    pub no_response_ratio: f64,
33    /// Of the exception responses, fraction that are subsequently reconciled
34    pub exception_reconciled_ratio: f64,
35}
36
37impl Default for ConfirmationGeneratorConfig {
38    fn default() -> Self {
39        Self {
40            confirmations_per_engagement: (5, 15),
41            bank_balance_ratio: 0.25,
42            accounts_receivable_ratio: 0.40,
43            confirmed_response_ratio: 0.70,
44            exception_response_ratio: 0.15,
45            no_response_ratio: 0.10,
46            exception_reconciled_ratio: 0.80,
47        }
48    }
49}
50
51/// Generator for external confirmations and responses per ISA 505.
52pub struct ConfirmationGenerator {
53    /// Seeded random number generator
54    rng: ChaCha8Rng,
55    /// Configuration
56    config: ConfirmationGeneratorConfig,
57    /// Monotone counter used for human-readable references
58    confirmation_counter: u32,
59}
60
61impl ConfirmationGenerator {
62    /// Create a new generator with the given seed and default configuration.
63    pub fn new(seed: u64) -> Self {
64        Self {
65            rng: seeded_rng(seed, 0),
66            config: ConfirmationGeneratorConfig::default(),
67            confirmation_counter: 0,
68        }
69    }
70
71    /// Create a new generator with custom configuration.
72    pub fn with_config(seed: u64, config: ConfirmationGeneratorConfig) -> Self {
73        Self {
74            rng: seeded_rng(seed, 0),
75            config,
76            confirmation_counter: 0,
77        }
78    }
79
80    /// Generate external confirmations and responses for an engagement.
81    ///
82    /// Returns a pair of vecs: `(confirmations, responses)`.  A response is
83    /// generated for every confirmation that does *not* remain in `NoResponse`
84    /// status, so the response vec may be shorter than the confirmation vec.
85    ///
86    /// # Arguments
87    /// * `engagement` — The audit engagement these confirmations belong to.
88    /// * `workpapers`  — Workpapers already generated for the engagement.  The
89    ///   generator links each confirmation to a randomly chosen substantive
90    ///   workpaper (if one exists).
91    /// * `account_codes` — GL account codes available in the client data.  Each
92    ///   confirmation will reference one of these codes when the slice is
93    ///   non-empty.
94    pub fn generate_confirmations(
95        &mut self,
96        engagement: &AuditEngagement,
97        workpapers: &[Workpaper],
98        account_codes: &[String],
99    ) -> (Vec<ExternalConfirmation>, Vec<ConfirmationResponse>) {
100        let count = self.rng.random_range(
101            self.config.confirmations_per_engagement.0..=self.config.confirmations_per_engagement.1,
102        ) as usize;
103
104        // Collect substantive workpapers as candidate link targets.
105        let substantive_wps: Vec<&Workpaper> = workpapers
106            .iter()
107            .filter(|wp| wp.section == WorkpaperSection::SubstantiveTesting)
108            .collect();
109
110        let mut confirmations = Vec::with_capacity(count);
111        let mut responses = Vec::with_capacity(count);
112
113        for i in 0..count {
114            let (conf_type, recipient_type, recipient_name) =
115                self.choose_confirmation_type(i, count);
116
117            // Pick a random account code if available.
118            let account_code: Option<String> = if account_codes.is_empty() {
119                None
120            } else {
121                let idx = self.rng.random_range(0..account_codes.len());
122                Some(account_codes[idx].clone())
123            };
124
125            // Realistic book balance: $10k – $5M
126            let balance_units: i64 = self.rng.random_range(10_000_i64..=5_000_000_i64);
127            let book_balance = Decimal::new(balance_units * 100, 2); // cents → dollars
128
129            // Confirmation date = period end date (the balance date being confirmed).
130            let confirmation_date = engagement.period_end_date;
131
132            // Sent during fieldwork: random day in fieldwork window.
133            let fieldwork_days = (engagement.fieldwork_end - engagement.fieldwork_start)
134                .num_days()
135                .max(1);
136            let sent_offset = self.rng.random_range(0..fieldwork_days);
137            let sent_date = engagement.fieldwork_start + Duration::days(sent_offset);
138            let deadline = sent_date + Duration::days(30);
139
140            self.confirmation_counter += 1;
141
142            let mut confirmation = ExternalConfirmation::new(
143                engagement.engagement_id,
144                conf_type,
145                &recipient_name,
146                recipient_type,
147                book_balance,
148                confirmation_date,
149            );
150
151            // Override ref to include a sequential counter for readability.
152            confirmation.confirmation_ref = format!(
153                "CONF-{}-{:04}",
154                engagement.fiscal_year, self.confirmation_counter
155            );
156
157            // Link to a substantive workpaper if one exists.
158            if !substantive_wps.is_empty() {
159                let wp_idx = self.rng.random_range(0..substantive_wps.len());
160                confirmation = confirmation.with_workpaper(substantive_wps[wp_idx].workpaper_id);
161            }
162
163            // Attach account code.
164            if let Some(ref code) = account_code {
165                confirmation = confirmation.with_account(code);
166            }
167
168            // Mark as sent.
169            confirmation.send(sent_date, deadline);
170
171            // Determine response outcome using configured ratios.
172            let roll: f64 = self.rng.random();
173            let no_response_cutoff = self.config.no_response_ratio;
174            let exception_cutoff = no_response_cutoff + self.config.exception_response_ratio;
175            let confirmed_cutoff = exception_cutoff + self.config.confirmed_response_ratio;
176            // Anything above confirmed_cutoff → Denied.
177
178            if roll < no_response_cutoff {
179                // No reply — confirmation stays without a response record.
180                confirmation.status = ConfirmationStatus::NoResponse;
181            } else {
182                // A response was received.
183                let response_days = self.rng.random_range(5_i64..=25_i64);
184                let response_date = sent_date + Duration::days(response_days);
185
186                let response_type = if roll < exception_cutoff {
187                    ResponseType::ConfirmedWithException
188                } else if roll < confirmed_cutoff {
189                    ResponseType::Confirmed
190                } else {
191                    ResponseType::Denied
192                };
193
194                let mut response = ConfirmationResponse::new(
195                    confirmation.confirmation_id,
196                    engagement.engagement_id,
197                    response_date,
198                    response_type,
199                );
200
201                match response_type {
202                    ResponseType::Confirmed => {
203                        // Confirming party agrees with book balance exactly.
204                        response = response.with_confirmed_balance(book_balance);
205                        confirmation.status = ConfirmationStatus::Completed;
206                    }
207                    ResponseType::ConfirmedWithException => {
208                        // Exception: confirmed balance differs by 1–8% of book.
209                        let exception_pct: f64 = self.rng.random_range(0.01..0.08);
210                        let exception_units = (balance_units as f64 * exception_pct).round() as i64;
211                        let exception_amount = Decimal::new(exception_units.max(1) * 100, 2);
212                        let confirmed_balance = book_balance - exception_amount;
213
214                        response = response
215                            .with_confirmed_balance(confirmed_balance)
216                            .with_exception(
217                                exception_amount,
218                                self.exception_description(conf_type),
219                            );
220
221                        // Optionally reconcile the exception.
222                        if self.rng.random::<f64>() < self.config.exception_reconciled_ratio {
223                            response.reconcile(
224                                "Difference investigated and reconciled to timing items \
225								 — no audit adjustment required.",
226                            );
227                        }
228
229                        confirmation.status = ConfirmationStatus::Completed;
230                    }
231                    ResponseType::Denied => {
232                        // Confirming party disagrees; no confirmed balance set.
233                        confirmation.status = ConfirmationStatus::AlternativeProcedures;
234                    }
235                    ResponseType::NoReply => {
236                        // Handled above — should not reach here.
237                        confirmation.status = ConfirmationStatus::NoResponse;
238                    }
239                }
240
241                responses.push(response);
242            }
243
244            confirmations.push(confirmation);
245        }
246
247        (confirmations, responses)
248    }
249
250    // -------------------------------------------------------------------------
251    // Private helpers
252    // -------------------------------------------------------------------------
253
254    /// Choose a confirmation type, recipient type, and a realistic name based
255    /// on configured ratios.  The `index` / `total` args allow even spread
256    /// across types rather than pure random (avoids clustering at small counts).
257    fn choose_confirmation_type(
258        &mut self,
259        index: usize,
260        total: usize,
261    ) -> (ConfirmationType, RecipientType, String) {
262        // Compute cumulative thresholds.
263        let bank_cutoff = self.config.bank_balance_ratio;
264        let ar_cutoff = bank_cutoff + self.config.accounts_receivable_ratio;
265        // Remaining split evenly between AP, Investment, Loan, Legal, Insurance, Inventory.
266        let remaining = 1.0 - ar_cutoff;
267        let other_each = remaining / 6.0;
268
269        // Spread confirmations evenly across types.
270        let fraction = (index as f64 + self.rng.random::<f64>()) / total.max(1) as f64;
271
272        if fraction < bank_cutoff {
273            let name = self.bank_name();
274            (ConfirmationType::BankBalance, RecipientType::Bank, name)
275        } else if fraction < ar_cutoff {
276            let name = self.customer_name();
277            (
278                ConfirmationType::AccountsReceivable,
279                RecipientType::Customer,
280                name,
281            )
282        } else if fraction < ar_cutoff + other_each {
283            let name = self.supplier_name();
284            (
285                ConfirmationType::AccountsPayable,
286                RecipientType::Supplier,
287                name,
288            )
289        } else if fraction < ar_cutoff + 2.0 * other_each {
290            let name = self.investment_firm_name();
291            (ConfirmationType::Investment, RecipientType::Other, name)
292        } else if fraction < ar_cutoff + 3.0 * other_each {
293            let name = self.bank_name();
294            (ConfirmationType::Loan, RecipientType::Bank, name)
295        } else if fraction < ar_cutoff + 4.0 * other_each {
296            let name = self.legal_firm_name();
297            (ConfirmationType::Legal, RecipientType::LegalCounsel, name)
298        } else if fraction < ar_cutoff + 5.0 * other_each {
299            let name = self.insurer_name();
300            (ConfirmationType::Insurance, RecipientType::Insurer, name)
301        } else {
302            let name = self.supplier_name();
303            (ConfirmationType::Inventory, RecipientType::Other, name)
304        }
305    }
306
307    fn bank_name(&mut self) -> String {
308        let banks = [
309            "First National Bank",
310            "City Commerce Bank",
311            "Meridian Federal Credit Union",
312            "Pacific Trust Bank",
313            "Atlantic Financial Corp",
314            "Heritage Savings Bank",
315            "Sunrise Bank plc",
316            "Continental Banking Group",
317        ];
318        let idx = self.rng.random_range(0..banks.len());
319        banks[idx].to_string()
320    }
321
322    fn customer_name(&mut self) -> String {
323        let names = [
324            "Acme Industries Ltd",
325            "Beacon Holdings PLC",
326            "Crestwood Manufacturing",
327            "Delta Retail Group",
328            "Epsilon Logistics Inc",
329            "Falcon Distribution SA",
330            "Global Supplies Corp",
331            "Horizon Trading Ltd",
332            "Irongate Wholesale",
333            "Jupiter Services LLC",
334        ];
335        let idx = self.rng.random_range(0..names.len());
336        names[idx].to_string()
337    }
338
339    fn supplier_name(&mut self) -> String {
340        let names = [
341            "Allied Components GmbH",
342            "BestSource Procurement",
343            "Cornerstone Supplies",
344            "Direct Parts Ltd",
345            "Eagle Procurement SA",
346            "Foundation Materials Inc",
347            "Granite Supply Co",
348        ];
349        let idx = self.rng.random_range(0..names.len());
350        names[idx].to_string()
351    }
352
353    fn investment_firm_name(&mut self) -> String {
354        let names = [
355            "Summit Asset Management",
356            "Veritas Capital Partners",
357            "Pinnacle Investment Trust",
358            "Apex Securities Ltd",
359        ];
360        let idx = self.rng.random_range(0..names.len());
361        names[idx].to_string()
362    }
363
364    fn legal_firm_name(&mut self) -> String {
365        let names = [
366            "Harrison & Webb LLP",
367            "Morrison Clarke Solicitors",
368            "Pemberton Legal Group",
369            "Sterling Advocates LLP",
370        ];
371        let idx = self.rng.random_range(0..names.len());
372        names[idx].to_string()
373    }
374
375    fn insurer_name(&mut self) -> String {
376        let names = [
377            "Centennial Insurance Co",
378            "Landmark Re Ltd",
379            "Prudential Assurance PLC",
380            "Shield Underwriters Ltd",
381        ];
382        let idx = self.rng.random_range(0..names.len());
383        names[idx].to_string()
384    }
385
386    fn exception_description(&self, conf_type: ConfirmationType) -> &'static str {
387        match conf_type {
388            ConfirmationType::BankBalance => {
389                "Outstanding cheque issued before year-end not yet presented for clearing"
390            }
391            ConfirmationType::AccountsReceivable => {
392                "Credit note raised before period end not yet reflected in client ledger"
393            }
394            ConfirmationType::AccountsPayable => {
395                "Goods received before year-end; supplier invoice recorded in following period"
396            }
397            ConfirmationType::Investment => {
398                "Accrued income on securities differs due to day-count convention"
399            }
400            ConfirmationType::Loan => {
401                "Accrued interest calculation basis differs from bank statement"
402            }
403            ConfirmationType::Legal => {
404                "Matter description differs from client disclosure — wording to be aligned"
405            }
406            ConfirmationType::Insurance => {
407                "Policy premium allocation differs by one month due to renewal date"
408            }
409            ConfirmationType::Inventory => {
410                "Consignment stock included in third-party count but excluded from client records"
411            }
412        }
413    }
414}
415
416// =============================================================================
417// Tests
418// =============================================================================
419
420#[cfg(test)]
421#[allow(clippy::unwrap_used)]
422mod tests {
423    use super::*;
424    use crate::audit::test_helpers::create_test_engagement;
425
426    fn make_gen(seed: u64) -> ConfirmationGenerator {
427        ConfirmationGenerator::new(seed)
428    }
429
430    fn empty_workpapers() -> Vec<Workpaper> {
431        Vec::new()
432    }
433
434    fn empty_accounts() -> Vec<String> {
435        Vec::new()
436    }
437
438    // -------------------------------------------------------------------------
439
440    /// Count is always within the configured (min, max) range.
441    #[test]
442    fn test_generates_expected_count() {
443        let engagement = create_test_engagement();
444        let mut gen = make_gen(42);
445        let (confs, _) =
446            gen.generate_confirmations(&engagement, &empty_workpapers(), &empty_accounts());
447
448        let min = ConfirmationGeneratorConfig::default()
449            .confirmations_per_engagement
450            .0 as usize;
451        let max = ConfirmationGeneratorConfig::default()
452            .confirmations_per_engagement
453            .1 as usize;
454        assert!(
455            confs.len() >= min && confs.len() <= max,
456            "expected {min}..={max}, got {}",
457            confs.len()
458        );
459    }
460
461    /// With many runs, roughly 70% of confirmations should get a "Confirmed" response.
462    #[test]
463    fn test_response_distribution() {
464        let engagement = create_test_engagement();
465        // Use a config that generates a large fixed count per run so we accumulate quickly.
466        let config = ConfirmationGeneratorConfig {
467            confirmations_per_engagement: (100, 100),
468            ..Default::default()
469        };
470        let mut gen = ConfirmationGenerator::with_config(99, config);
471        let (confs, responses) =
472            gen.generate_confirmations(&engagement, &empty_workpapers(), &empty_accounts());
473
474        let total = confs.len() as f64;
475        let confirmed_count = responses
476            .iter()
477            .filter(|r| r.response_type == ResponseType::Confirmed)
478            .count() as f64;
479
480        // Expect within ±15% of the 70% target (i.e., 55–85%).
481        let ratio = confirmed_count / total;
482        assert!(
483            (0.55..=0.85).contains(&ratio),
484            "confirmed ratio {ratio:.2} outside expected 55–85%"
485        );
486    }
487
488    /// Exception amounts should be a small fraction (1–8%) of the book balance.
489    #[test]
490    fn test_exception_amounts() {
491        let engagement = create_test_engagement();
492        let config = ConfirmationGeneratorConfig {
493            confirmations_per_engagement: (200, 200),
494            exception_response_ratio: 0.50, // inflate exceptions so we get enough samples
495            confirmed_response_ratio: 0.40,
496            no_response_ratio: 0.05,
497            ..Default::default()
498        };
499        let mut gen = ConfirmationGenerator::with_config(77, config);
500        let (confs, responses) =
501            gen.generate_confirmations(&engagement, &empty_workpapers(), &empty_accounts());
502
503        // Build a lookup from confirmation_id → book_balance.
504        let book_map: std::collections::HashMap<uuid::Uuid, Decimal> = confs
505            .iter()
506            .map(|c| (c.confirmation_id, c.book_balance))
507            .collect();
508
509        let exceptions: Vec<&ConfirmationResponse> =
510            responses.iter().filter(|r| r.has_exception).collect();
511
512        assert!(
513            !exceptions.is_empty(),
514            "expected at least some exception responses"
515        );
516
517        for resp in &exceptions {
518            let book = *book_map.get(&resp.confirmation_id).unwrap();
519            let exc = resp.exception_amount.unwrap();
520            // exc should be 1–8% of book balance (plus small rounding).
521            let ratio = (exc / book).to_string().parse::<f64>().unwrap_or(1.0);
522            assert!(
523                ratio > 0.0 && ratio <= 0.09,
524                "exception ratio {ratio:.4} out of expected 0–9% for book={book}, exc={exc}"
525            );
526        }
527    }
528
529    /// Same seed must produce identical output (deterministic PRNG).
530    #[test]
531    fn test_deterministic_with_seed() {
532        let engagement = create_test_engagement();
533        let accounts = vec!["1010".to_string(), "1200".to_string(), "2100".to_string()];
534
535        let (confs_a, resp_a) = {
536            let mut gen = make_gen(1234);
537            gen.generate_confirmations(&engagement, &empty_workpapers(), &accounts)
538        };
539        let (confs_b, resp_b) = {
540            let mut gen = make_gen(1234);
541            gen.generate_confirmations(&engagement, &empty_workpapers(), &accounts)
542        };
543
544        assert_eq!(
545            confs_a.len(),
546            confs_b.len(),
547            "confirmation counts differ across identical seeds"
548        );
549        assert_eq!(
550            resp_a.len(),
551            resp_b.len(),
552            "response counts differ across identical seeds"
553        );
554
555        for (a, b) in confs_a.iter().zip(confs_b.iter()) {
556            assert_eq!(a.confirmation_ref, b.confirmation_ref);
557            assert_eq!(a.book_balance, b.book_balance);
558            assert_eq!(a.status, b.status);
559            assert_eq!(a.confirmation_type, b.confirmation_type);
560        }
561    }
562
563    /// Confirmations link to the provided account codes.
564    #[test]
565    fn test_account_codes_linked() {
566        let engagement = create_test_engagement();
567        let accounts = vec!["ACC-001".to_string(), "ACC-002".to_string()];
568        let mut gen = make_gen(55);
569        let (confs, _) = gen.generate_confirmations(&engagement, &empty_workpapers(), &accounts);
570
571        // Every confirmation should have an account_id from our list.
572        for conf in &confs {
573            assert!(
574                conf.account_id.as_deref().is_some(),
575                "confirmation {} should have an account_id",
576                conf.confirmation_ref
577            );
578            assert!(
579                accounts.contains(conf.account_id.as_ref().unwrap()),
580                "account_id '{}' not in provided list",
581                conf.account_id.as_ref().unwrap()
582            );
583        }
584    }
585
586    /// When a substantive workpaper is provided, confirmations should link to it.
587    #[test]
588    fn test_workpaper_linking() {
589        use datasynth_core::models::audit::WorkpaperSection;
590
591        let engagement = create_test_engagement();
592        // Build a minimal substantive workpaper so we can test linking.
593        let wp = Workpaper::new(
594            engagement.engagement_id,
595            "D-001",
596            "Test Workpaper",
597            WorkpaperSection::SubstantiveTesting,
598        );
599        let wp_id = wp.workpaper_id;
600
601        let mut gen = make_gen(71);
602        let (confs, _) = gen.generate_confirmations(&engagement, &[wp], &empty_accounts());
603
604        // All confirmations should be linked to the single substantive workpaper.
605        for conf in &confs {
606            assert_eq!(
607                conf.workpaper_id,
608                Some(wp_id),
609                "confirmation {} should link to workpaper {wp_id}",
610                conf.confirmation_ref
611            );
612        }
613    }
614}