Skip to main content

datasynth_generators/standards/
confirmation_generator.rs

1//! ISA 505 External Confirmation generator.
2//!
3//! Produces [`ExternalConfirmation`] instances for audit engagements, covering
4//! accounts receivable, accounts payable, bank, and legal confirmations with
5//! realistic response statuses, reconciliation details, and alternative
6//! procedures for non-responses.
7
8use chrono::NaiveDate;
9use datasynth_core::utils::seeded_rng;
10use rand::Rng;
11use rand_chacha::ChaCha8Rng;
12use rust_decimal::Decimal;
13use uuid::Uuid;
14
15use datasynth_standards::audit::confirmation::{
16    AlternativeProcedureConclusion, AlternativeProcedureReason, AlternativeProcedures,
17    ConfirmationConclusion, ConfirmationForm, ConfirmationReconciliation, ConfirmationResponse,
18    ConfirmationResponseStatus, ConfirmationType, ExternalConfirmation, ReconcilingItem,
19    ReconcilingItemType, ResponseReliability,
20};
21
22/// Configuration for the confirmation generator.
23#[derive(Debug, Clone)]
24pub struct ConfirmationGeneratorConfig {
25    /// Total number of confirmations to generate.
26    pub confirmation_count: usize,
27    /// Positive response rate (0.0..1.0).
28    pub positive_response_rate: f64,
29    /// Exception rate (disagreements among responses).
30    pub exception_rate: f64,
31    /// Non-response rate.
32    pub non_response_rate: f64,
33    /// Type distribution: [AR, AP, Bank, Legal] (rest is Other).
34    pub type_weights: [f64; 4],
35}
36
37impl Default for ConfirmationGeneratorConfig {
38    fn default() -> Self {
39        Self {
40            confirmation_count: 50,
41            positive_response_rate: 0.85,
42            exception_rate: 0.10,
43            non_response_rate: 0.10,
44            type_weights: [0.40, 0.30, 0.20, 0.10],
45        }
46    }
47}
48
49/// Generates [`ExternalConfirmation`] instances for a given audit engagement.
50pub struct ConfirmationGenerator {
51    rng: ChaCha8Rng,
52    config: ConfirmationGeneratorConfig,
53    confirmation_counter: usize,
54}
55
56/// Discriminator added to the seed so this generator's RNG stream does not
57/// overlap with other generators that may share the same base seed.
58const SEED_DISCRIMINATOR: u64 = 0xAE_0E;
59
60impl ConfirmationGenerator {
61    /// Create a new generator with the given seed and default config.
62    pub fn new(seed: u64) -> Self {
63        Self {
64            rng: seeded_rng(seed, SEED_DISCRIMINATOR),
65            config: ConfirmationGeneratorConfig::default(),
66            confirmation_counter: 0,
67        }
68    }
69
70    /// Create a new generator with the given seed and custom config.
71    pub fn with_config(seed: u64, config: ConfirmationGeneratorConfig) -> Self {
72        Self {
73            rng: seeded_rng(seed, SEED_DISCRIMINATOR),
74            config,
75            confirmation_counter: 0,
76        }
77    }
78
79    /// Generate confirmations for an audit engagement.
80    ///
81    /// Returns `config.confirmation_count` confirmations with realistic
82    /// response statuses, reconciliation details, and alternative procedures.
83    pub fn generate_confirmations(
84        &mut self,
85        engagement_id: Uuid,
86        base_date: NaiveDate,
87    ) -> Vec<ExternalConfirmation> {
88        let count = self.config.confirmation_count;
89        let mut confirmations = Vec::with_capacity(count);
90
91        for _ in 0..count {
92            self.confirmation_counter += 1;
93            let confirmation = self.build_confirmation(engagement_id, base_date);
94            confirmations.push(confirmation);
95        }
96
97        confirmations
98    }
99
100    /// Pick a confirmation type from the configured weights.
101    fn pick_confirmation_type(&mut self) -> ConfirmationType {
102        let weights = &self.config.type_weights;
103        let total: f64 = weights.iter().sum();
104        let mut r: f64 = self.rng.random_range(0.0..total);
105
106        for (i, &w) in weights.iter().enumerate() {
107            r -= w;
108            if r <= 0.0 {
109                return match i {
110                    0 => ConfirmationType::AccountsReceivable,
111                    1 => ConfirmationType::AccountsPayable,
112                    2 => ConfirmationType::Bank,
113                    _ => ConfirmationType::Legal,
114                };
115            }
116        }
117        ConfirmationType::AccountsReceivable
118    }
119
120    /// Generate a confirmee name based on confirmation type.
121    fn generate_confirmee_name(&mut self, conf_type: ConfirmationType) -> String {
122        let n = self.confirmation_counter;
123        match conf_type {
124            ConfirmationType::AccountsReceivable => format!("Customer-{}", n),
125            ConfirmationType::AccountsPayable => format!("Vendor-{}", n),
126            ConfirmationType::Bank => {
127                let cities = ["New York", "London", "Chicago", "Dallas", "Boston"];
128                let idx = self.rng.random_range(0..cities.len());
129                format!("Bank of {}", cities[idx])
130            }
131            ConfirmationType::Legal => format!("Law Office {}", n),
132            _ => format!("Confirmee-{}", n),
133        }
134    }
135
136    /// Generate client amount based on confirmation type.
137    fn generate_client_amount(&mut self, conf_type: ConfirmationType) -> Decimal {
138        match conf_type {
139            ConfirmationType::AccountsReceivable | ConfirmationType::AccountsPayable => {
140                Decimal::from(self.rng.random_range(1000..500_000_i64))
141            }
142            ConfirmationType::Bank => Decimal::from(self.rng.random_range(50_000..5_000_000_i64)),
143            ConfirmationType::Legal => Decimal::from(self.rng.random_range(500..100_000_i64)),
144            _ => Decimal::from(self.rng.random_range(1000..500_000_i64)),
145        }
146    }
147
148    /// Generate an item description based on confirmation type.
149    fn generate_item_description(&self, conf_type: ConfirmationType) -> String {
150        match conf_type {
151            ConfirmationType::AccountsReceivable => "Trade receivable balance".to_string(),
152            ConfirmationType::AccountsPayable => "Trade payable balance".to_string(),
153            ConfirmationType::Bank => "Bank account balance".to_string(),
154            ConfirmationType::Legal => "Legal matters and contingencies".to_string(),
155            _ => "Account balance".to_string(),
156        }
157    }
158
159    /// Build a single confirmation with all its details.
160    fn build_confirmation(
161        &mut self,
162        engagement_id: Uuid,
163        base_date: NaiveDate,
164    ) -> ExternalConfirmation {
165        let conf_type = self.pick_confirmation_type();
166        let confirmee_name = self.generate_confirmee_name(conf_type);
167        let client_amount = self.generate_client_amount(conf_type);
168        let item_description = self.generate_item_description(conf_type);
169
170        let days_offset = self.rng.random_range(0..14_i64);
171        let date_sent = base_date + chrono::Duration::days(days_offset);
172
173        let mut confirmation = ExternalConfirmation::new(
174            engagement_id,
175            conf_type,
176            &confirmee_name,
177            &item_description,
178            client_amount,
179            "USD",
180        );
181
182        confirmation.confirmation_form = ConfirmationForm::Positive;
183        confirmation.date_sent = date_sent;
184        confirmation.prepared_by = format!("Audit Staff {}", self.confirmation_counter);
185        confirmation.workpaper_reference =
186            Some(format!("WP-CONF-{:04}", self.confirmation_counter));
187
188        // Determine response status
189        let roll: f64 = self.rng.random_range(0.0..1.0);
190
191        if roll < self.config.non_response_rate {
192            // No response
193            self.apply_no_response(&mut confirmation, date_sent);
194        } else {
195            // Got some response; distribute among agrees/disagrees/partial
196            let remaining_roll: f64 = self.rng.random_range(0.0..1.0);
197
198            if remaining_roll < self.config.positive_response_rate {
199                self.apply_received_agrees(&mut confirmation, date_sent, client_amount);
200            } else if remaining_roll
201                < self.config.positive_response_rate + self.config.exception_rate
202            {
203                self.apply_received_disagrees(&mut confirmation, date_sent, client_amount);
204            } else {
205                self.apply_received_partial(&mut confirmation, date_sent, client_amount);
206            }
207        }
208
209        // Set follow-up date for pending/no-response
210        if matches!(
211            confirmation.response_status,
212            ConfirmationResponseStatus::Pending | ConfirmationResponseStatus::NoResponse
213        ) {
214            confirmation.follow_up_date = Some(date_sent + chrono::Duration::days(14));
215        }
216
217        confirmation
218    }
219
220    /// Apply ReceivedAgrees status to a confirmation.
221    fn apply_received_agrees(
222        &mut self,
223        confirmation: &mut ExternalConfirmation,
224        date_sent: NaiveDate,
225        client_amount: Decimal,
226    ) {
227        let response_days = self.rng.random_range(7..30_i64);
228        let date_received = date_sent + chrono::Duration::days(response_days);
229
230        let mut response = ConfirmationResponse::new(date_received, client_amount, true);
231        response.respondent_name = format!("{} - Authorized Signer", confirmation.confirmee_name);
232        response.appears_authentic = true;
233        response.reliability_assessment = ResponseReliability::Reliable;
234
235        confirmation.response_status = ConfirmationResponseStatus::ReceivedAgrees;
236        confirmation.response = Some(response);
237        confirmation.conclusion = ConfirmationConclusion::Confirmed;
238    }
239
240    /// Apply ReceivedDisagrees status to a confirmation.
241    fn apply_received_disagrees(
242        &mut self,
243        confirmation: &mut ExternalConfirmation,
244        date_sent: NaiveDate,
245        client_amount: Decimal,
246    ) {
247        let response_days = self.rng.random_range(7..30_i64);
248        let date_received = date_sent + chrono::Duration::days(response_days);
249
250        // Confirmed amount differs by 0.90..1.10 factor
251        let factor: f64 = self.rng.random_range(0.90..1.10);
252        let factor_decimal = Decimal::from_f64_retain(factor).unwrap_or(Decimal::ONE);
253        let confirmed_amount = client_amount * factor_decimal;
254
255        let mut response = ConfirmationResponse::new(date_received, confirmed_amount, false);
256        response.respondent_name = format!("{} - Authorized Signer", confirmation.confirmee_name);
257        response.appears_authentic = true;
258        response.reliability_assessment = ResponseReliability::Reliable;
259
260        confirmation.response_status = ConfirmationResponseStatus::ReceivedDisagrees;
261        confirmation.response = Some(response);
262
263        // Create reconciliation
264        let mut reconciliation = ConfirmationReconciliation::new(client_amount, confirmed_amount);
265
266        // Add a reconciling item
267        let item_type = if self.rng.random_bool(0.5) {
268            ReconcilingItemType::CashInTransit
269        } else {
270            ReconcilingItemType::CutoffAdjustment
271        };
272
273        let difference = client_amount - confirmed_amount;
274        let item = ReconcilingItem {
275            description: match item_type {
276                ReconcilingItemType::CashInTransit => "Payment in transit".to_string(),
277                ReconcilingItemType::CutoffAdjustment => "Cutoff timing difference".to_string(),
278                _ => "Other reconciling item".to_string(),
279            },
280            amount: difference,
281            item_type,
282            evidence: "Examined supporting documentation".to_string(),
283        };
284        reconciliation.add_reconciling_item(item);
285
286        confirmation.reconciliation = Some(reconciliation);
287
288        // Conclusion: 80% ExceptionResolved, 20% PotentialMisstatement
289        if self.rng.random_bool(0.80) {
290            confirmation.conclusion = ConfirmationConclusion::ExceptionResolved;
291        } else {
292            confirmation.conclusion = ConfirmationConclusion::PotentialMisstatement;
293        }
294    }
295
296    /// Apply NoResponse status with alternative procedures.
297    fn apply_no_response(&mut self, confirmation: &mut ExternalConfirmation, date_sent: NaiveDate) {
298        confirmation.response_status = ConfirmationResponseStatus::NoResponse;
299        confirmation.follow_up_date = Some(date_sent + chrono::Duration::days(14));
300
301        let mut alt_procedures = AlternativeProcedures::new(AlternativeProcedureReason::NoResponse);
302        alt_procedures
303            .evidence_obtained
304            .push("Reviewed subsequent transactions".to_string());
305        alt_procedures
306            .evidence_obtained
307            .push("Examined supporting documentation".to_string());
308
309        // Conclusion: 90% satisfactory, 10% insufficient
310        if self.rng.random_bool(0.90) {
311            alt_procedures.conclusion = AlternativeProcedureConclusion::SufficientEvidence;
312            confirmation.conclusion = ConfirmationConclusion::AlternativesSatisfactory;
313        } else {
314            alt_procedures.conclusion = AlternativeProcedureConclusion::InsufficientEvidence;
315            confirmation.conclusion = ConfirmationConclusion::InsufficientEvidence;
316        }
317
318        confirmation.alternative_procedures = Some(alt_procedures);
319    }
320
321    /// Apply ReceivedPartial status to a confirmation.
322    fn apply_received_partial(
323        &mut self,
324        confirmation: &mut ExternalConfirmation,
325        date_sent: NaiveDate,
326        client_amount: Decimal,
327    ) {
328        let response_days = self.rng.random_range(7..30_i64);
329        let date_received = date_sent + chrono::Duration::days(response_days);
330
331        // Partial amount: 50-90% of client amount
332        let partial_factor: f64 = self.rng.random_range(0.50..0.90);
333        let partial_decimal =
334            Decimal::from_f64_retain(partial_factor).unwrap_or(Decimal::new(70, 2));
335        let partial_amount = client_amount * partial_decimal;
336
337        let mut response = ConfirmationResponse::new(date_received, partial_amount, false);
338        response.respondent_name = format!("{} - Authorized Signer", confirmation.confirmee_name);
339        response.appears_authentic = true;
340        response.reliability_assessment = ResponseReliability::Reliable;
341        response.comments = "Partial information provided".to_string();
342
343        confirmation.response_status = ConfirmationResponseStatus::ReceivedPartial;
344        confirmation.response = Some(response);
345        confirmation.conclusion = ConfirmationConclusion::ExceptionResolved;
346    }
347}
348
349#[cfg(test)]
350#[allow(clippy::unwrap_used)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn test_deterministic_generation() {
356        let engagement_id = Uuid::nil();
357        let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
358
359        let mut gen1 = ConfirmationGenerator::new(42);
360        let mut gen2 = ConfirmationGenerator::new(42);
361
362        let results1 = gen1.generate_confirmations(engagement_id, base_date);
363        let results2 = gen2.generate_confirmations(engagement_id, base_date);
364
365        assert_eq!(results1.len(), results2.len());
366        for (c1, c2) in results1.iter().zip(results2.iter()) {
367            assert_eq!(c1.confirmee_name, c2.confirmee_name);
368            assert_eq!(c1.client_amount, c2.client_amount);
369            assert_eq!(c1.response_status, c2.response_status);
370            assert_eq!(c1.date_sent, c2.date_sent);
371            assert_eq!(c1.prepared_by, c2.prepared_by);
372        }
373    }
374
375    #[test]
376    fn test_confirmation_count() {
377        let engagement_id = Uuid::nil();
378        let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
379
380        let config = ConfirmationGeneratorConfig {
381            confirmation_count: 25,
382            ..Default::default()
383        };
384        let mut gen = ConfirmationGenerator::with_config(42, config);
385        let results = gen.generate_confirmations(engagement_id, base_date);
386
387        assert_eq!(results.len(), 25);
388    }
389
390    #[test]
391    fn test_type_distribution() {
392        let engagement_id = Uuid::nil();
393        let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
394
395        let config = ConfirmationGeneratorConfig {
396            confirmation_count: 200,
397            ..Default::default()
398        };
399        let mut gen = ConfirmationGenerator::with_config(42, config);
400        let results = gen.generate_confirmations(engagement_id, base_date);
401
402        let ar_count = results
403            .iter()
404            .filter(|c| c.confirmation_type == ConfirmationType::AccountsReceivable)
405            .count();
406        let ap_count = results
407            .iter()
408            .filter(|c| c.confirmation_type == ConfirmationType::AccountsPayable)
409            .count();
410        let bank_count = results
411            .iter()
412            .filter(|c| c.confirmation_type == ConfirmationType::Bank)
413            .count();
414        let legal_count = results
415            .iter()
416            .filter(|c| c.confirmation_type == ConfirmationType::Legal)
417            .count();
418
419        // With weights [0.40, 0.30, 0.20, 0.10], AR > AP > Bank > Legal
420        assert!(
421            ar_count > ap_count,
422            "AR ({}) should exceed AP ({})",
423            ar_count,
424            ap_count
425        );
426        assert!(
427            ap_count > bank_count,
428            "AP ({}) should exceed Bank ({})",
429            ap_count,
430            bank_count
431        );
432        assert!(
433            bank_count > legal_count,
434            "Bank ({}) should exceed Legal ({})",
435            bank_count,
436            legal_count
437        );
438    }
439
440    #[test]
441    fn test_positive_response_rate() {
442        let engagement_id = Uuid::nil();
443        let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
444
445        let config = ConfirmationGeneratorConfig {
446            confirmation_count: 100,
447            positive_response_rate: 1.0,
448            exception_rate: 0.0,
449            non_response_rate: 0.0,
450            type_weights: [1.0, 0.0, 0.0, 0.0],
451        };
452        let mut gen = ConfirmationGenerator::with_config(42, config);
453        let results = gen.generate_confirmations(engagement_id, base_date);
454
455        for c in &results {
456            assert_eq!(
457                c.response_status,
458                ConfirmationResponseStatus::ReceivedAgrees,
459                "All should be ReceivedAgrees when positive_response_rate=1.0"
460            );
461        }
462    }
463
464    #[test]
465    fn test_non_response_generates_alternatives() {
466        let engagement_id = Uuid::nil();
467        let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
468
469        let config = ConfirmationGeneratorConfig {
470            confirmation_count: 100,
471            positive_response_rate: 0.0,
472            exception_rate: 0.0,
473            non_response_rate: 1.0,
474            type_weights: [1.0, 0.0, 0.0, 0.0],
475        };
476        let mut gen = ConfirmationGenerator::with_config(42, config);
477        let results = gen.generate_confirmations(engagement_id, base_date);
478
479        for c in &results {
480            assert_eq!(c.response_status, ConfirmationResponseStatus::NoResponse);
481            assert!(
482                c.alternative_procedures.is_some(),
483                "NoResponse confirmations must have alternative_procedures"
484            );
485        }
486    }
487
488    #[test]
489    fn test_disagreements_have_reconciliation() {
490        let engagement_id = Uuid::nil();
491        let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
492
493        // Force all to be disagrees: non_response_rate=0, positive_response_rate=0,
494        // exception_rate=1.0 so the "remaining" roll always lands in disagree range.
495        let config = ConfirmationGeneratorConfig {
496            confirmation_count: 50,
497            positive_response_rate: 0.0,
498            exception_rate: 1.0,
499            non_response_rate: 0.0,
500            type_weights: [1.0, 0.0, 0.0, 0.0],
501        };
502        let mut gen = ConfirmationGenerator::with_config(42, config);
503        let results = gen.generate_confirmations(engagement_id, base_date);
504
505        let disagrees: Vec<_> = results
506            .iter()
507            .filter(|c| c.response_status == ConfirmationResponseStatus::ReceivedDisagrees)
508            .collect();
509
510        assert!(!disagrees.is_empty(), "Should have some disagreements");
511
512        for c in &disagrees {
513            assert!(
514                c.reconciliation.is_some(),
515                "ReceivedDisagrees confirmations must have reconciliation"
516            );
517        }
518    }
519
520    #[test]
521    fn test_all_confirmations_have_prepared_by() {
522        let engagement_id = Uuid::nil();
523        let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
524
525        let mut gen = ConfirmationGenerator::new(42);
526        let results = gen.generate_confirmations(engagement_id, base_date);
527
528        for c in &results {
529            assert!(
530                !c.prepared_by.is_empty(),
531                "All confirmations must have non-empty prepared_by"
532            );
533        }
534    }
535
536    #[test]
537    fn test_zero_non_response_rate() {
538        let engagement_id = Uuid::nil();
539        let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
540
541        let config = ConfirmationGeneratorConfig {
542            confirmation_count: 100,
543            positive_response_rate: 0.85,
544            exception_rate: 0.10,
545            non_response_rate: 0.0,
546            type_weights: [0.40, 0.30, 0.20, 0.10],
547        };
548        let mut gen = ConfirmationGenerator::with_config(42, config);
549        let results = gen.generate_confirmations(engagement_id, base_date);
550
551        let no_responses = results
552            .iter()
553            .filter(|c| c.response_status == ConfirmationResponseStatus::NoResponse)
554            .count();
555
556        assert_eq!(
557            no_responses, 0,
558            "With non_response_rate=0.0, there should be no NoResponse statuses"
559        );
560    }
561}