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::RngExt;
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)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn test_deterministic_generation() {
355        let engagement_id = Uuid::nil();
356        let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
357
358        let mut gen1 = ConfirmationGenerator::new(42);
359        let mut gen2 = ConfirmationGenerator::new(42);
360
361        let results1 = gen1.generate_confirmations(engagement_id, base_date);
362        let results2 = gen2.generate_confirmations(engagement_id, base_date);
363
364        assert_eq!(results1.len(), results2.len());
365        for (c1, c2) in results1.iter().zip(results2.iter()) {
366            assert_eq!(c1.confirmee_name, c2.confirmee_name);
367            assert_eq!(c1.client_amount, c2.client_amount);
368            assert_eq!(c1.response_status, c2.response_status);
369            assert_eq!(c1.date_sent, c2.date_sent);
370            assert_eq!(c1.prepared_by, c2.prepared_by);
371        }
372    }
373
374    #[test]
375    fn test_confirmation_count() {
376        let engagement_id = Uuid::nil();
377        let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
378
379        let config = ConfirmationGeneratorConfig {
380            confirmation_count: 25,
381            ..Default::default()
382        };
383        let mut gen = ConfirmationGenerator::with_config(42, config);
384        let results = gen.generate_confirmations(engagement_id, base_date);
385
386        assert_eq!(results.len(), 25);
387    }
388
389    #[test]
390    fn test_type_distribution() {
391        let engagement_id = Uuid::nil();
392        let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
393
394        let config = ConfirmationGeneratorConfig {
395            confirmation_count: 200,
396            ..Default::default()
397        };
398        let mut gen = ConfirmationGenerator::with_config(42, config);
399        let results = gen.generate_confirmations(engagement_id, base_date);
400
401        let ar_count = results
402            .iter()
403            .filter(|c| c.confirmation_type == ConfirmationType::AccountsReceivable)
404            .count();
405        let ap_count = results
406            .iter()
407            .filter(|c| c.confirmation_type == ConfirmationType::AccountsPayable)
408            .count();
409        let bank_count = results
410            .iter()
411            .filter(|c| c.confirmation_type == ConfirmationType::Bank)
412            .count();
413        let legal_count = results
414            .iter()
415            .filter(|c| c.confirmation_type == ConfirmationType::Legal)
416            .count();
417
418        // With weights [0.40, 0.30, 0.20, 0.10], AR > AP > Bank > Legal
419        assert!(
420            ar_count > ap_count,
421            "AR ({}) should exceed AP ({})",
422            ar_count,
423            ap_count
424        );
425        assert!(
426            ap_count > bank_count,
427            "AP ({}) should exceed Bank ({})",
428            ap_count,
429            bank_count
430        );
431        assert!(
432            bank_count > legal_count,
433            "Bank ({}) should exceed Legal ({})",
434            bank_count,
435            legal_count
436        );
437    }
438
439    #[test]
440    fn test_positive_response_rate() {
441        let engagement_id = Uuid::nil();
442        let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
443
444        let config = ConfirmationGeneratorConfig {
445            confirmation_count: 100,
446            positive_response_rate: 1.0,
447            exception_rate: 0.0,
448            non_response_rate: 0.0,
449            type_weights: [1.0, 0.0, 0.0, 0.0],
450        };
451        let mut gen = ConfirmationGenerator::with_config(42, config);
452        let results = gen.generate_confirmations(engagement_id, base_date);
453
454        for c in &results {
455            assert_eq!(
456                c.response_status,
457                ConfirmationResponseStatus::ReceivedAgrees,
458                "All should be ReceivedAgrees when positive_response_rate=1.0"
459            );
460        }
461    }
462
463    #[test]
464    fn test_non_response_generates_alternatives() {
465        let engagement_id = Uuid::nil();
466        let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
467
468        let config = ConfirmationGeneratorConfig {
469            confirmation_count: 100,
470            positive_response_rate: 0.0,
471            exception_rate: 0.0,
472            non_response_rate: 1.0,
473            type_weights: [1.0, 0.0, 0.0, 0.0],
474        };
475        let mut gen = ConfirmationGenerator::with_config(42, config);
476        let results = gen.generate_confirmations(engagement_id, base_date);
477
478        for c in &results {
479            assert_eq!(c.response_status, ConfirmationResponseStatus::NoResponse);
480            assert!(
481                c.alternative_procedures.is_some(),
482                "NoResponse confirmations must have alternative_procedures"
483            );
484        }
485    }
486
487    #[test]
488    fn test_disagreements_have_reconciliation() {
489        let engagement_id = Uuid::nil();
490        let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
491
492        // Force all to be disagrees: non_response_rate=0, positive_response_rate=0,
493        // exception_rate=1.0 so the "remaining" roll always lands in disagree range.
494        let config = ConfirmationGeneratorConfig {
495            confirmation_count: 50,
496            positive_response_rate: 0.0,
497            exception_rate: 1.0,
498            non_response_rate: 0.0,
499            type_weights: [1.0, 0.0, 0.0, 0.0],
500        };
501        let mut gen = ConfirmationGenerator::with_config(42, config);
502        let results = gen.generate_confirmations(engagement_id, base_date);
503
504        let disagrees: Vec<_> = results
505            .iter()
506            .filter(|c| c.response_status == ConfirmationResponseStatus::ReceivedDisagrees)
507            .collect();
508
509        assert!(!disagrees.is_empty(), "Should have some disagreements");
510
511        for c in &disagrees {
512            assert!(
513                c.reconciliation.is_some(),
514                "ReceivedDisagrees confirmations must have reconciliation"
515            );
516        }
517    }
518
519    #[test]
520    fn test_all_confirmations_have_prepared_by() {
521        let engagement_id = Uuid::nil();
522        let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
523
524        let mut gen = ConfirmationGenerator::new(42);
525        let results = gen.generate_confirmations(engagement_id, base_date);
526
527        for c in &results {
528            assert!(
529                !c.prepared_by.is_empty(),
530                "All confirmations must have non-empty prepared_by"
531            );
532        }
533    }
534
535    #[test]
536    fn test_zero_non_response_rate() {
537        let engagement_id = Uuid::nil();
538        let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
539
540        let config = ConfirmationGeneratorConfig {
541            confirmation_count: 100,
542            positive_response_rate: 0.85,
543            exception_rate: 0.10,
544            non_response_rate: 0.0,
545            type_weights: [0.40, 0.30, 0.20, 0.10],
546        };
547        let mut gen = ConfirmationGenerator::with_config(42, config);
548        let results = gen.generate_confirmations(engagement_id, base_date);
549
550        let no_responses = results
551            .iter()
552            .filter(|c| c.response_status == ConfirmationResponseStatus::NoResponse)
553            .count();
554
555        assert_eq!(
556            no_responses, 0,
557            "With non_response_rate=0.0, there should be no NoResponse statuses"
558        );
559    }
560}