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 std::collections::HashMap;
8
9use chrono::Duration;
10use datasynth_core::utils::seeded_rng;
11use rand::RngExt;
12use rand_chacha::ChaCha8Rng;
13use rust_decimal::prelude::FromPrimitive;
14use rust_decimal::Decimal;
15
16use datasynth_core::models::audit::{
17    AuditEngagement, ConfirmationResponse, ConfirmationStatus, ConfirmationType,
18    ExternalConfirmation, RecipientType, ResponseType, Workpaper, WorkpaperSection,
19};
20
21/// Configuration for external confirmation generation (ISA 505).
22#[derive(Debug, Clone)]
23pub struct ConfirmationGeneratorConfig {
24    /// Number of confirmations per engagement (min, max)
25    pub confirmations_per_engagement: (u32, u32),
26    /// Fraction of confirmations that are bank balance type
27    pub bank_balance_ratio: f64,
28    /// Fraction of confirmations that are accounts receivable type
29    pub accounts_receivable_ratio: f64,
30    /// Fraction of confirmations that receive a clean "Confirmed" response
31    pub confirmed_response_ratio: f64,
32    /// Fraction of confirmations that receive a "ConfirmedWithException" response
33    pub exception_response_ratio: f64,
34    /// Fraction of confirmations that receive no reply
35    pub no_response_ratio: f64,
36    /// Of the exception responses, fraction that are subsequently reconciled
37    pub exception_reconciled_ratio: f64,
38}
39
40impl Default for ConfirmationGeneratorConfig {
41    fn default() -> Self {
42        Self {
43            confirmations_per_engagement: (5, 15),
44            bank_balance_ratio: 0.25,
45            accounts_receivable_ratio: 0.40,
46            confirmed_response_ratio: 0.70,
47            exception_response_ratio: 0.15,
48            no_response_ratio: 0.10,
49            exception_reconciled_ratio: 0.80,
50        }
51    }
52}
53
54/// Generator for external confirmations and responses per ISA 505.
55pub struct ConfirmationGenerator {
56    /// Seeded random number generator
57    rng: ChaCha8Rng,
58    /// Configuration
59    config: ConfirmationGeneratorConfig,
60    /// Monotone counter used for human-readable references
61    confirmation_counter: u32,
62}
63
64impl ConfirmationGenerator {
65    /// Create a new generator with the given seed and default configuration.
66    pub fn new(seed: u64) -> Self {
67        Self {
68            rng: seeded_rng(seed, 0),
69            config: ConfirmationGeneratorConfig::default(),
70            confirmation_counter: 0,
71        }
72    }
73
74    /// Create a new generator with custom configuration.
75    pub fn with_config(seed: u64, config: ConfirmationGeneratorConfig) -> Self {
76        Self {
77            rng: seeded_rng(seed, 0),
78            config,
79            confirmation_counter: 0,
80        }
81    }
82
83    /// Generate external confirmations and responses for an engagement.
84    ///
85    /// Returns a pair of vecs: `(confirmations, responses)`.  A response is
86    /// generated for every confirmation that does *not* remain in `NoResponse`
87    /// status, so the response vec may be shorter than the confirmation vec.
88    ///
89    /// # Arguments
90    /// * `engagement` — The audit engagement these confirmations belong to.
91    /// * `workpapers`  — Workpapers already generated for the engagement.  The
92    ///   generator links each confirmation to a randomly chosen substantive
93    ///   workpaper (if one exists).
94    /// * `account_codes` — GL account codes available in the client data.  Each
95    ///   confirmation will reference one of these codes when the slice is
96    ///   non-empty.
97    pub fn generate_confirmations(
98        &mut self,
99        engagement: &AuditEngagement,
100        workpapers: &[Workpaper],
101        account_codes: &[String],
102    ) -> (Vec<ExternalConfirmation>, Vec<ConfirmationResponse>) {
103        let count = self.rng.random_range(
104            self.config.confirmations_per_engagement.0..=self.config.confirmations_per_engagement.1,
105        ) as usize;
106
107        // Collect substantive workpapers as candidate link targets.
108        let substantive_wps: Vec<&Workpaper> = workpapers
109            .iter()
110            .filter(|wp| wp.section == WorkpaperSection::SubstantiveTesting)
111            .collect();
112
113        let mut confirmations = Vec::with_capacity(count);
114        let mut responses = Vec::with_capacity(count);
115
116        for i in 0..count {
117            let (conf_type, recipient_type, recipient_name) =
118                self.choose_confirmation_type(i, count);
119
120            // Pick a random account code if available.
121            let account_code: Option<String> = if account_codes.is_empty() {
122                None
123            } else {
124                let idx = self.rng.random_range(0..account_codes.len());
125                Some(account_codes[idx].clone())
126            };
127
128            // Realistic book balance: $10k – $5M
129            let balance_units: i64 = self.rng.random_range(10_000_i64..=5_000_000_i64);
130            let book_balance = Decimal::new(balance_units * 100, 2); // cents → dollars
131
132            // Confirmation date = period end date (the balance date being confirmed).
133            let confirmation_date = engagement.period_end_date;
134
135            // Sent during fieldwork: random day in fieldwork window.
136            let fieldwork_days = (engagement.fieldwork_end - engagement.fieldwork_start)
137                .num_days()
138                .max(1);
139            let sent_offset = self.rng.random_range(0..fieldwork_days);
140            let sent_date = engagement.fieldwork_start + Duration::days(sent_offset);
141            let deadline = sent_date + Duration::days(30);
142
143            self.confirmation_counter += 1;
144
145            let mut confirmation = ExternalConfirmation::new(
146                engagement.engagement_id,
147                conf_type,
148                &recipient_name,
149                recipient_type,
150                book_balance,
151                confirmation_date,
152            );
153
154            // Override ref to include a sequential counter for readability.
155            confirmation.confirmation_ref = format!(
156                "CONF-{}-{:04}",
157                engagement.fiscal_year, self.confirmation_counter
158            );
159
160            // Link to a substantive workpaper if one exists.
161            if !substantive_wps.is_empty() {
162                let wp_idx = self.rng.random_range(0..substantive_wps.len());
163                confirmation = confirmation.with_workpaper(substantive_wps[wp_idx].workpaper_id);
164            }
165
166            // Attach account code.
167            if let Some(ref code) = account_code {
168                confirmation = confirmation.with_account(code);
169            }
170
171            // Mark as sent.
172            confirmation.send(sent_date, deadline);
173
174            // Determine response outcome using configured ratios.
175            let roll: f64 = self.rng.random();
176            let no_response_cutoff = self.config.no_response_ratio;
177            let exception_cutoff = no_response_cutoff + self.config.exception_response_ratio;
178            let confirmed_cutoff = exception_cutoff + self.config.confirmed_response_ratio;
179            // Anything above confirmed_cutoff → Denied.
180
181            if roll < no_response_cutoff {
182                // No reply — confirmation stays without a response record.
183                confirmation.status = ConfirmationStatus::NoResponse;
184            } else {
185                // A response was received.
186                let response_days = self.rng.random_range(5_i64..=25_i64);
187                let response_date = sent_date + Duration::days(response_days);
188
189                let response_type = if roll < exception_cutoff {
190                    ResponseType::ConfirmedWithException
191                } else if roll < confirmed_cutoff {
192                    ResponseType::Confirmed
193                } else {
194                    ResponseType::Denied
195                };
196
197                let mut response = ConfirmationResponse::new(
198                    confirmation.confirmation_id,
199                    engagement.engagement_id,
200                    response_date,
201                    response_type,
202                );
203
204                match response_type {
205                    ResponseType::Confirmed => {
206                        // Confirming party agrees with book balance exactly.
207                        response = response.with_confirmed_balance(book_balance);
208                        confirmation.status = ConfirmationStatus::Completed;
209                    }
210                    ResponseType::ConfirmedWithException => {
211                        // Exception: confirmed balance differs by 1–8% of book.
212                        let exception_pct: f64 = self.rng.random_range(0.01..0.08);
213                        let exception_units = (balance_units as f64 * exception_pct).round() as i64;
214                        let exception_amount = Decimal::new(exception_units.max(1) * 100, 2);
215                        let confirmed_balance = book_balance - exception_amount;
216
217                        response = response
218                            .with_confirmed_balance(confirmed_balance)
219                            .with_exception(
220                                exception_amount,
221                                self.exception_description(conf_type),
222                            );
223
224                        // Optionally reconcile the exception.
225                        if self.rng.random::<f64>() < self.config.exception_reconciled_ratio {
226                            response.reconcile(
227                                "Difference investigated and reconciled to timing items \
228								 — no audit adjustment required.",
229                            );
230                        }
231
232                        confirmation.status = ConfirmationStatus::Completed;
233                    }
234                    ResponseType::Denied => {
235                        // Confirming party disagrees; no confirmed balance set.
236                        confirmation.status = ConfirmationStatus::AlternativeProcedures;
237                    }
238                    ResponseType::NoReply => {
239                        // Handled above — should not reach here.
240                        confirmation.status = ConfirmationStatus::NoResponse;
241                    }
242                }
243
244                responses.push(response);
245            }
246
247            confirmations.push(confirmation);
248        }
249
250        (confirmations, responses)
251    }
252
253    /// Generate confirmations using real account balances for book values.
254    ///
255    /// When a confirmation is of type `BankBalance`, `AccountsReceivable`, or
256    /// `AccountsPayable`, the book balance is derived from matching GL accounts
257    /// in `account_balances` (keyed by GL account code).  If no matching
258    /// balance is found, falls back to the existing synthetic generation.
259    ///
260    /// The response logic (confirmed, exception, no reply) is identical to the
261    /// base method.
262    pub fn generate_confirmations_with_balances(
263        &mut self,
264        engagement: &AuditEngagement,
265        workpapers: &[Workpaper],
266        account_codes: &[String],
267        account_balances: &HashMap<String, f64>,
268    ) -> (Vec<ExternalConfirmation>, Vec<ConfirmationResponse>) {
269        let count = self.rng.random_range(
270            self.config.confirmations_per_engagement.0..=self.config.confirmations_per_engagement.1,
271        ) as usize;
272
273        let substantive_wps: Vec<&Workpaper> = workpapers
274            .iter()
275            .filter(|wp| wp.section == WorkpaperSection::SubstantiveTesting)
276            .collect();
277
278        let mut confirmations = Vec::with_capacity(count);
279        let mut responses = Vec::with_capacity(count);
280
281        // Pre-compute aggregate balances for each confirmation category.
282        let bank_balance: f64 = account_balances
283            .iter()
284            .filter(|(code, _)| code.starts_with("10"))
285            .map(|(_, bal)| bal.abs())
286            .sum();
287        let ar_balance: f64 = account_balances
288            .iter()
289            .filter(|(code, _)| code.starts_with("11"))
290            .map(|(_, bal)| bal.abs())
291            .sum();
292        let ap_balance: f64 = account_balances
293            .iter()
294            .filter(|(code, _)| code.starts_with("20"))
295            .map(|(_, bal)| bal.abs())
296            .sum();
297
298        for i in 0..count {
299            let (conf_type, recipient_type, recipient_name) =
300                self.choose_confirmation_type(i, count);
301
302            let account_code: Option<String> = if account_codes.is_empty() {
303                None
304            } else {
305                let idx = self.rng.random_range(0..account_codes.len());
306                Some(account_codes[idx].clone())
307            };
308
309            // Use real balances when available for the matching confirmation type.
310            let real_balance = match conf_type {
311                ConfirmationType::BankBalance | ConfirmationType::Loan => bank_balance,
312                ConfirmationType::AccountsReceivable => ar_balance,
313                ConfirmationType::AccountsPayable => ap_balance,
314                _ => 0.0,
315            };
316
317            // Synthetic fallback: $10k - $5M (same as original generate_confirmations).
318            let synthetic_units: i64 = self.rng.random_range(10_000_i64..=5_000_000_i64);
319            let synthetic_balance = Decimal::new(synthetic_units * 100, 2);
320
321            let book_balance = if real_balance > 0.0 {
322                Decimal::from_f64(real_balance).unwrap_or(synthetic_balance)
323            } else {
324                synthetic_balance
325            };
326            let balance_units_for_exception = if real_balance > 0.0 {
327                real_balance as i64
328            } else {
329                synthetic_units
330            };
331
332            let confirmation_date = engagement.period_end_date;
333
334            let fieldwork_days = (engagement.fieldwork_end - engagement.fieldwork_start)
335                .num_days()
336                .max(1);
337            let sent_offset = self.rng.random_range(0..fieldwork_days);
338            let sent_date = engagement.fieldwork_start + Duration::days(sent_offset);
339            let deadline = sent_date + Duration::days(30);
340
341            self.confirmation_counter += 1;
342
343            let mut confirmation = ExternalConfirmation::new(
344                engagement.engagement_id,
345                conf_type,
346                &recipient_name,
347                recipient_type,
348                book_balance,
349                confirmation_date,
350            );
351
352            confirmation.confirmation_ref = format!(
353                "CONF-{}-{:04}",
354                engagement.fiscal_year, self.confirmation_counter
355            );
356
357            if !substantive_wps.is_empty() {
358                let wp_idx = self.rng.random_range(0..substantive_wps.len());
359                confirmation = confirmation.with_workpaper(substantive_wps[wp_idx].workpaper_id);
360            }
361
362            if let Some(ref code) = account_code {
363                confirmation = confirmation.with_account(code);
364            }
365
366            confirmation.send(sent_date, deadline);
367
368            // Determine response outcome (identical logic to generate_confirmations).
369            let roll: f64 = self.rng.random();
370            let no_response_cutoff = self.config.no_response_ratio;
371            let exception_cutoff = no_response_cutoff + self.config.exception_response_ratio;
372            let confirmed_cutoff = exception_cutoff + self.config.confirmed_response_ratio;
373
374            if roll < no_response_cutoff {
375                confirmation.status = ConfirmationStatus::NoResponse;
376            } else {
377                let response_days = self.rng.random_range(5_i64..=25_i64);
378                let response_date = sent_date + Duration::days(response_days);
379
380                let response_type = if roll < exception_cutoff {
381                    ResponseType::ConfirmedWithException
382                } else if roll < confirmed_cutoff {
383                    ResponseType::Confirmed
384                } else {
385                    ResponseType::Denied
386                };
387
388                let mut response = ConfirmationResponse::new(
389                    confirmation.confirmation_id,
390                    engagement.engagement_id,
391                    response_date,
392                    response_type,
393                );
394
395                match response_type {
396                    ResponseType::Confirmed => {
397                        response = response.with_confirmed_balance(book_balance);
398                        confirmation.status = ConfirmationStatus::Completed;
399                    }
400                    ResponseType::ConfirmedWithException => {
401                        let exception_pct: f64 = self.rng.random_range(0.01..0.08);
402                        let exception_units =
403                            (balance_units_for_exception as f64 * exception_pct).round() as i64;
404                        let exception_amount = Decimal::new(exception_units.max(1) * 100, 2);
405                        let confirmed_balance = book_balance - exception_amount;
406
407                        response = response
408                            .with_confirmed_balance(confirmed_balance)
409                            .with_exception(
410                                exception_amount,
411                                self.exception_description(conf_type),
412                            );
413
414                        if self.rng.random::<f64>() < self.config.exception_reconciled_ratio {
415                            response.reconcile(
416                                "Difference investigated and reconciled to timing items \
417                                 — no audit adjustment required.",
418                            );
419                        }
420
421                        confirmation.status = ConfirmationStatus::Completed;
422                    }
423                    ResponseType::Denied => {
424                        confirmation.status = ConfirmationStatus::AlternativeProcedures;
425                    }
426                    ResponseType::NoReply => {
427                        confirmation.status = ConfirmationStatus::NoResponse;
428                    }
429                }
430
431                responses.push(response);
432            }
433
434            confirmations.push(confirmation);
435        }
436
437        (confirmations, responses)
438    }
439
440    // -------------------------------------------------------------------------
441    // Private helpers
442    // -------------------------------------------------------------------------
443
444    /// Choose a confirmation type, recipient type, and a realistic name based
445    /// on configured ratios.  The `index` / `total` args allow even spread
446    /// across types rather than pure random (avoids clustering at small counts).
447    fn choose_confirmation_type(
448        &mut self,
449        index: usize,
450        total: usize,
451    ) -> (ConfirmationType, RecipientType, String) {
452        // Compute cumulative thresholds.
453        let bank_cutoff = self.config.bank_balance_ratio;
454        let ar_cutoff = bank_cutoff + self.config.accounts_receivable_ratio;
455        // Remaining split evenly between AP, Investment, Loan, Legal, Insurance, Inventory.
456        let remaining = 1.0 - ar_cutoff;
457        let other_each = remaining / 6.0;
458
459        // Spread confirmations evenly across types.
460        let fraction = (index as f64 + self.rng.random::<f64>()) / total.max(1) as f64;
461
462        if fraction < bank_cutoff {
463            let name = self.bank_name();
464            (ConfirmationType::BankBalance, RecipientType::Bank, name)
465        } else if fraction < ar_cutoff {
466            let name = self.customer_name();
467            (
468                ConfirmationType::AccountsReceivable,
469                RecipientType::Customer,
470                name,
471            )
472        } else if fraction < ar_cutoff + other_each {
473            let name = self.supplier_name();
474            (
475                ConfirmationType::AccountsPayable,
476                RecipientType::Supplier,
477                name,
478            )
479        } else if fraction < ar_cutoff + 2.0 * other_each {
480            let name = self.investment_firm_name();
481            (ConfirmationType::Investment, RecipientType::Other, name)
482        } else if fraction < ar_cutoff + 3.0 * other_each {
483            let name = self.bank_name();
484            (ConfirmationType::Loan, RecipientType::Bank, name)
485        } else if fraction < ar_cutoff + 4.0 * other_each {
486            let name = self.legal_firm_name();
487            (ConfirmationType::Legal, RecipientType::LegalCounsel, name)
488        } else if fraction < ar_cutoff + 5.0 * other_each {
489            let name = self.insurer_name();
490            (ConfirmationType::Insurance, RecipientType::Insurer, name)
491        } else {
492            let name = self.supplier_name();
493            (ConfirmationType::Inventory, RecipientType::Other, name)
494        }
495    }
496
497    fn bank_name(&mut self) -> String {
498        let banks = [
499            "First National Bank",
500            "City Commerce Bank",
501            "Meridian Federal Credit Union",
502            "Pacific Trust Bank",
503            "Atlantic Financial Corp",
504            "Heritage Savings Bank",
505            "Sunrise Bank plc",
506            "Continental Banking Group",
507        ];
508        let idx = self.rng.random_range(0..banks.len());
509        banks[idx].to_string()
510    }
511
512    fn customer_name(&mut self) -> String {
513        let names = [
514            "Acme Industries Ltd",
515            "Beacon Holdings PLC",
516            "Crestwood Manufacturing",
517            "Delta Retail Group",
518            "Epsilon Logistics Inc",
519            "Falcon Distribution SA",
520            "Global Supplies Corp",
521            "Horizon Trading Ltd",
522            "Irongate Wholesale",
523            "Jupiter Services LLC",
524        ];
525        let idx = self.rng.random_range(0..names.len());
526        names[idx].to_string()
527    }
528
529    fn supplier_name(&mut self) -> String {
530        let names = [
531            "Allied Components GmbH",
532            "BestSource Procurement",
533            "Cornerstone Supplies",
534            "Direct Parts Ltd",
535            "Eagle Procurement SA",
536            "Foundation Materials Inc",
537            "Granite Supply Co",
538        ];
539        let idx = self.rng.random_range(0..names.len());
540        names[idx].to_string()
541    }
542
543    fn investment_firm_name(&mut self) -> String {
544        let names = [
545            "Summit Asset Management",
546            "Veritas Capital Partners",
547            "Pinnacle Investment Trust",
548            "Apex Securities Ltd",
549        ];
550        let idx = self.rng.random_range(0..names.len());
551        names[idx].to_string()
552    }
553
554    fn legal_firm_name(&mut self) -> String {
555        let names = [
556            "Harrison & Webb LLP",
557            "Morrison Clarke Solicitors",
558            "Pemberton Legal Group",
559            "Sterling Advocates LLP",
560        ];
561        let idx = self.rng.random_range(0..names.len());
562        names[idx].to_string()
563    }
564
565    fn insurer_name(&mut self) -> String {
566        let names = [
567            "Centennial Insurance Co",
568            "Landmark Re Ltd",
569            "Prudential Assurance PLC",
570            "Shield Underwriters Ltd",
571        ];
572        let idx = self.rng.random_range(0..names.len());
573        names[idx].to_string()
574    }
575
576    fn exception_description(&self, conf_type: ConfirmationType) -> &'static str {
577        match conf_type {
578            ConfirmationType::BankBalance => {
579                "Outstanding cheque issued before year-end not yet presented for clearing"
580            }
581            ConfirmationType::AccountsReceivable => {
582                "Credit note raised before period end not yet reflected in client ledger"
583            }
584            ConfirmationType::AccountsPayable => {
585                "Goods received before year-end; supplier invoice recorded in following period"
586            }
587            ConfirmationType::Investment => {
588                "Accrued income on securities differs due to day-count convention"
589            }
590            ConfirmationType::Loan => {
591                "Accrued interest calculation basis differs from bank statement"
592            }
593            ConfirmationType::Legal => {
594                "Matter description differs from client disclosure — wording to be aligned"
595            }
596            ConfirmationType::Insurance => {
597                "Policy premium allocation differs by one month due to renewal date"
598            }
599            ConfirmationType::Inventory => {
600                "Consignment stock included in third-party count but excluded from client records"
601            }
602        }
603    }
604}
605
606// =============================================================================
607// Tests
608// =============================================================================
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use crate::audit::test_helpers::create_test_engagement;
614
615    fn make_gen(seed: u64) -> ConfirmationGenerator {
616        ConfirmationGenerator::new(seed)
617    }
618
619    fn empty_workpapers() -> Vec<Workpaper> {
620        Vec::new()
621    }
622
623    fn empty_accounts() -> Vec<String> {
624        Vec::new()
625    }
626
627    // -------------------------------------------------------------------------
628
629    /// Count is always within the configured (min, max) range.
630    #[test]
631    fn test_generates_expected_count() {
632        let engagement = create_test_engagement();
633        let mut gen = make_gen(42);
634        let (confs, _) =
635            gen.generate_confirmations(&engagement, &empty_workpapers(), &empty_accounts());
636
637        let min = ConfirmationGeneratorConfig::default()
638            .confirmations_per_engagement
639            .0 as usize;
640        let max = ConfirmationGeneratorConfig::default()
641            .confirmations_per_engagement
642            .1 as usize;
643        assert!(
644            confs.len() >= min && confs.len() <= max,
645            "expected {min}..={max}, got {}",
646            confs.len()
647        );
648    }
649
650    /// With many runs, roughly 70% of confirmations should get a "Confirmed" response.
651    #[test]
652    fn test_response_distribution() {
653        let engagement = create_test_engagement();
654        // Use a config that generates a large fixed count per run so we accumulate quickly.
655        let config = ConfirmationGeneratorConfig {
656            confirmations_per_engagement: (100, 100),
657            ..Default::default()
658        };
659        let mut gen = ConfirmationGenerator::with_config(99, config);
660        let (confs, responses) =
661            gen.generate_confirmations(&engagement, &empty_workpapers(), &empty_accounts());
662
663        let total = confs.len() as f64;
664        let confirmed_count = responses
665            .iter()
666            .filter(|r| r.response_type == ResponseType::Confirmed)
667            .count() as f64;
668
669        // Expect within ±15% of the 70% target (i.e., 55–85%).
670        let ratio = confirmed_count / total;
671        assert!(
672            (0.55..=0.85).contains(&ratio),
673            "confirmed ratio {ratio:.2} outside expected 55–85%"
674        );
675    }
676
677    /// Exception amounts should be a small fraction (1–8%) of the book balance.
678    #[test]
679    fn test_exception_amounts() {
680        let engagement = create_test_engagement();
681        let config = ConfirmationGeneratorConfig {
682            confirmations_per_engagement: (200, 200),
683            exception_response_ratio: 0.50, // inflate exceptions so we get enough samples
684            confirmed_response_ratio: 0.40,
685            no_response_ratio: 0.05,
686            ..Default::default()
687        };
688        let mut gen = ConfirmationGenerator::with_config(77, config);
689        let (confs, responses) =
690            gen.generate_confirmations(&engagement, &empty_workpapers(), &empty_accounts());
691
692        // Build a lookup from confirmation_id → book_balance.
693        let book_map: std::collections::HashMap<uuid::Uuid, Decimal> = confs
694            .iter()
695            .map(|c| (c.confirmation_id, c.book_balance))
696            .collect();
697
698        let exceptions: Vec<&ConfirmationResponse> =
699            responses.iter().filter(|r| r.has_exception).collect();
700
701        assert!(
702            !exceptions.is_empty(),
703            "expected at least some exception responses"
704        );
705
706        for resp in &exceptions {
707            let book = *book_map.get(&resp.confirmation_id).unwrap();
708            let exc = resp.exception_amount.unwrap();
709            // exc should be 1–8% of book balance (plus small rounding).
710            let ratio = (exc / book).to_string().parse::<f64>().unwrap_or(1.0);
711            assert!(
712                ratio > 0.0 && ratio <= 0.09,
713                "exception ratio {ratio:.4} out of expected 0–9% for book={book}, exc={exc}"
714            );
715        }
716    }
717
718    /// Same seed must produce identical output (deterministic PRNG).
719    #[test]
720    fn test_deterministic_with_seed() {
721        let engagement = create_test_engagement();
722        let accounts = vec!["1010".to_string(), "1200".to_string(), "2100".to_string()];
723
724        let (confs_a, resp_a) = {
725            let mut gen = make_gen(1234);
726            gen.generate_confirmations(&engagement, &empty_workpapers(), &accounts)
727        };
728        let (confs_b, resp_b) = {
729            let mut gen = make_gen(1234);
730            gen.generate_confirmations(&engagement, &empty_workpapers(), &accounts)
731        };
732
733        assert_eq!(
734            confs_a.len(),
735            confs_b.len(),
736            "confirmation counts differ across identical seeds"
737        );
738        assert_eq!(
739            resp_a.len(),
740            resp_b.len(),
741            "response counts differ across identical seeds"
742        );
743
744        for (a, b) in confs_a.iter().zip(confs_b.iter()) {
745            assert_eq!(a.confirmation_ref, b.confirmation_ref);
746            assert_eq!(a.book_balance, b.book_balance);
747            assert_eq!(a.status, b.status);
748            assert_eq!(a.confirmation_type, b.confirmation_type);
749        }
750    }
751
752    /// Confirmations link to the provided account codes.
753    #[test]
754    fn test_account_codes_linked() {
755        let engagement = create_test_engagement();
756        let accounts = vec!["ACC-001".to_string(), "ACC-002".to_string()];
757        let mut gen = make_gen(55);
758        let (confs, _) = gen.generate_confirmations(&engagement, &empty_workpapers(), &accounts);
759
760        // Every confirmation should have an account_id from our list.
761        for conf in &confs {
762            assert!(
763                conf.account_id.as_deref().is_some(),
764                "confirmation {} should have an account_id",
765                conf.confirmation_ref
766            );
767            assert!(
768                accounts.contains(conf.account_id.as_ref().unwrap()),
769                "account_id '{}' not in provided list",
770                conf.account_id.as_ref().unwrap()
771            );
772        }
773    }
774
775    /// When a substantive workpaper is provided, confirmations should link to it.
776    #[test]
777    fn test_workpaper_linking() {
778        use datasynth_core::models::audit::WorkpaperSection;
779
780        let engagement = create_test_engagement();
781        // Build a minimal substantive workpaper so we can test linking.
782        let wp = Workpaper::new(
783            engagement.engagement_id,
784            "D-001",
785            "Test Workpaper",
786            WorkpaperSection::SubstantiveTesting,
787        );
788        let wp_id = wp.workpaper_id;
789
790        let mut gen = make_gen(71);
791        let (confs, _) = gen.generate_confirmations(&engagement, &[wp], &empty_accounts());
792
793        // All confirmations should be linked to the single substantive workpaper.
794        for conf in &confs {
795            assert_eq!(
796                conf.workpaper_id,
797                Some(wp_id),
798                "confirmation {} should link to workpaper {wp_id}",
799                conf.confirmation_ref
800            );
801        }
802    }
803
804    /// Confirmations with real balances use the supplied AR/AP/Cash amounts.
805    #[test]
806    fn test_balance_weighted_confirmations_use_real_balances() {
807        use datasynth_core::models::audit::ConfirmationType;
808
809        let engagement = create_test_engagement();
810        let accounts = vec!["1100".to_string(), "2000".to_string(), "1010".to_string()];
811        let balances = HashMap::from([
812            ("1100".into(), 1_250_000.0), // AR
813            ("2000".into(), 875_000.0),   // AP
814            ("1010".into(), 500_000.0),   // Cash/Bank
815        ]);
816
817        let config = ConfirmationGeneratorConfig {
818            confirmations_per_engagement: (30, 30),
819            ..Default::default()
820        };
821        let mut gen = ConfirmationGenerator::with_config(42, config);
822        let (confs, _) = gen.generate_confirmations_with_balances(
823            &engagement,
824            &empty_workpapers(),
825            &accounts,
826            &balances,
827        );
828
829        assert!(!confs.is_empty());
830
831        // AR confirmations should have book_balance equal to the AR total (1,250,000).
832        let ar_confs: Vec<_> = confs
833            .iter()
834            .filter(|c| c.confirmation_type == ConfirmationType::AccountsReceivable)
835            .collect();
836        for conf in &ar_confs {
837            let expected = Decimal::from_f64(1_250_000.0).unwrap();
838            assert_eq!(
839                conf.book_balance, expected,
840                "AR confirmation should use real AR balance"
841            );
842        }
843
844        // Bank confirmations should use Cash balance (500,000).
845        let bank_confs: Vec<_> = confs
846            .iter()
847            .filter(|c| c.confirmation_type == ConfirmationType::BankBalance)
848            .collect();
849        for conf in &bank_confs {
850            let expected = Decimal::from_f64(500_000.0).unwrap();
851            assert_eq!(
852                conf.book_balance, expected,
853                "Bank confirmation should use real Cash balance"
854            );
855        }
856
857        // AP confirmations should use AP balance (875,000).
858        let ap_confs: Vec<_> = confs
859            .iter()
860            .filter(|c| c.confirmation_type == ConfirmationType::AccountsPayable)
861            .collect();
862        for conf in &ap_confs {
863            let expected = Decimal::from_f64(875_000.0).unwrap();
864            assert_eq!(
865                conf.book_balance, expected,
866                "AP confirmation should use real AP balance"
867            );
868        }
869    }
870
871    /// When balances are empty, the balance-weighted method falls back to synthetic values.
872    #[test]
873    fn test_balance_weighted_empty_balances_uses_synthetic() {
874        let engagement = create_test_engagement();
875        let accounts = vec!["1100".to_string()];
876        let empty_balances: HashMap<String, f64> = HashMap::new();
877
878        let mut gen = make_gen(42);
879        let (confs, _) = gen.generate_confirmations_with_balances(
880            &engagement,
881            &empty_workpapers(),
882            &accounts,
883            &empty_balances,
884        );
885
886        assert!(!confs.is_empty());
887        // All book balances should be in the synthetic $10k-$5M range.
888        for conf in &confs {
889            let bal = conf.book_balance;
890            assert!(
891                bal >= Decimal::new(1_000_000, 2) && bal <= Decimal::new(500_000_000, 2),
892                "expected synthetic balance in 10k-5M range, got {bal}"
893            );
894        }
895    }
896}