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)]
611#[allow(clippy::unwrap_used)]
612mod tests {
613    use super::*;
614    use crate::audit::test_helpers::create_test_engagement;
615
616    fn make_gen(seed: u64) -> ConfirmationGenerator {
617        ConfirmationGenerator::new(seed)
618    }
619
620    fn empty_workpapers() -> Vec<Workpaper> {
621        Vec::new()
622    }
623
624    fn empty_accounts() -> Vec<String> {
625        Vec::new()
626    }
627
628    // -------------------------------------------------------------------------
629
630    /// Count is always within the configured (min, max) range.
631    #[test]
632    fn test_generates_expected_count() {
633        let engagement = create_test_engagement();
634        let mut gen = make_gen(42);
635        let (confs, _) =
636            gen.generate_confirmations(&engagement, &empty_workpapers(), &empty_accounts());
637
638        let min = ConfirmationGeneratorConfig::default()
639            .confirmations_per_engagement
640            .0 as usize;
641        let max = ConfirmationGeneratorConfig::default()
642            .confirmations_per_engagement
643            .1 as usize;
644        assert!(
645            confs.len() >= min && confs.len() <= max,
646            "expected {min}..={max}, got {}",
647            confs.len()
648        );
649    }
650
651    /// With many runs, roughly 70% of confirmations should get a "Confirmed" response.
652    #[test]
653    fn test_response_distribution() {
654        let engagement = create_test_engagement();
655        // Use a config that generates a large fixed count per run so we accumulate quickly.
656        let config = ConfirmationGeneratorConfig {
657            confirmations_per_engagement: (100, 100),
658            ..Default::default()
659        };
660        let mut gen = ConfirmationGenerator::with_config(99, config);
661        let (confs, responses) =
662            gen.generate_confirmations(&engagement, &empty_workpapers(), &empty_accounts());
663
664        let total = confs.len() as f64;
665        let confirmed_count = responses
666            .iter()
667            .filter(|r| r.response_type == ResponseType::Confirmed)
668            .count() as f64;
669
670        // Expect within ±15% of the 70% target (i.e., 55–85%).
671        let ratio = confirmed_count / total;
672        assert!(
673            (0.55..=0.85).contains(&ratio),
674            "confirmed ratio {ratio:.2} outside expected 55–85%"
675        );
676    }
677
678    /// Exception amounts should be a small fraction (1–8%) of the book balance.
679    #[test]
680    fn test_exception_amounts() {
681        let engagement = create_test_engagement();
682        let config = ConfirmationGeneratorConfig {
683            confirmations_per_engagement: (200, 200),
684            exception_response_ratio: 0.50, // inflate exceptions so we get enough samples
685            confirmed_response_ratio: 0.40,
686            no_response_ratio: 0.05,
687            ..Default::default()
688        };
689        let mut gen = ConfirmationGenerator::with_config(77, config);
690        let (confs, responses) =
691            gen.generate_confirmations(&engagement, &empty_workpapers(), &empty_accounts());
692
693        // Build a lookup from confirmation_id → book_balance.
694        let book_map: std::collections::HashMap<uuid::Uuid, Decimal> = confs
695            .iter()
696            .map(|c| (c.confirmation_id, c.book_balance))
697            .collect();
698
699        let exceptions: Vec<&ConfirmationResponse> =
700            responses.iter().filter(|r| r.has_exception).collect();
701
702        assert!(
703            !exceptions.is_empty(),
704            "expected at least some exception responses"
705        );
706
707        for resp in &exceptions {
708            let book = *book_map.get(&resp.confirmation_id).unwrap();
709            let exc = resp.exception_amount.unwrap();
710            // exc should be 1–8% of book balance (plus small rounding).
711            let ratio = (exc / book).to_string().parse::<f64>().unwrap_or(1.0);
712            assert!(
713                ratio > 0.0 && ratio <= 0.09,
714                "exception ratio {ratio:.4} out of expected 0–9% for book={book}, exc={exc}"
715            );
716        }
717    }
718
719    /// Same seed must produce identical output (deterministic PRNG).
720    #[test]
721    fn test_deterministic_with_seed() {
722        let engagement = create_test_engagement();
723        let accounts = vec!["1010".to_string(), "1200".to_string(), "2100".to_string()];
724
725        let (confs_a, resp_a) = {
726            let mut gen = make_gen(1234);
727            gen.generate_confirmations(&engagement, &empty_workpapers(), &accounts)
728        };
729        let (confs_b, resp_b) = {
730            let mut gen = make_gen(1234);
731            gen.generate_confirmations(&engagement, &empty_workpapers(), &accounts)
732        };
733
734        assert_eq!(
735            confs_a.len(),
736            confs_b.len(),
737            "confirmation counts differ across identical seeds"
738        );
739        assert_eq!(
740            resp_a.len(),
741            resp_b.len(),
742            "response counts differ across identical seeds"
743        );
744
745        for (a, b) in confs_a.iter().zip(confs_b.iter()) {
746            assert_eq!(a.confirmation_ref, b.confirmation_ref);
747            assert_eq!(a.book_balance, b.book_balance);
748            assert_eq!(a.status, b.status);
749            assert_eq!(a.confirmation_type, b.confirmation_type);
750        }
751    }
752
753    /// Confirmations link to the provided account codes.
754    #[test]
755    fn test_account_codes_linked() {
756        let engagement = create_test_engagement();
757        let accounts = vec!["ACC-001".to_string(), "ACC-002".to_string()];
758        let mut gen = make_gen(55);
759        let (confs, _) = gen.generate_confirmations(&engagement, &empty_workpapers(), &accounts);
760
761        // Every confirmation should have an account_id from our list.
762        for conf in &confs {
763            assert!(
764                conf.account_id.as_deref().is_some(),
765                "confirmation {} should have an account_id",
766                conf.confirmation_ref
767            );
768            assert!(
769                accounts.contains(conf.account_id.as_ref().unwrap()),
770                "account_id '{}' not in provided list",
771                conf.account_id.as_ref().unwrap()
772            );
773        }
774    }
775
776    /// When a substantive workpaper is provided, confirmations should link to it.
777    #[test]
778    fn test_workpaper_linking() {
779        use datasynth_core::models::audit::WorkpaperSection;
780
781        let engagement = create_test_engagement();
782        // Build a minimal substantive workpaper so we can test linking.
783        let wp = Workpaper::new(
784            engagement.engagement_id,
785            "D-001",
786            "Test Workpaper",
787            WorkpaperSection::SubstantiveTesting,
788        );
789        let wp_id = wp.workpaper_id;
790
791        let mut gen = make_gen(71);
792        let (confs, _) = gen.generate_confirmations(&engagement, &[wp], &empty_accounts());
793
794        // All confirmations should be linked to the single substantive workpaper.
795        for conf in &confs {
796            assert_eq!(
797                conf.workpaper_id,
798                Some(wp_id),
799                "confirmation {} should link to workpaper {wp_id}",
800                conf.confirmation_ref
801            );
802        }
803    }
804
805    /// Confirmations with real balances use the supplied AR/AP/Cash amounts.
806    #[test]
807    fn test_balance_weighted_confirmations_use_real_balances() {
808        use datasynth_core::models::audit::ConfirmationType;
809
810        let engagement = create_test_engagement();
811        let accounts = vec!["1100".to_string(), "2000".to_string(), "1010".to_string()];
812        let balances = HashMap::from([
813            ("1100".into(), 1_250_000.0), // AR
814            ("2000".into(), 875_000.0),   // AP
815            ("1010".into(), 500_000.0),   // Cash/Bank
816        ]);
817
818        let config = ConfirmationGeneratorConfig {
819            confirmations_per_engagement: (30, 30),
820            ..Default::default()
821        };
822        let mut gen = ConfirmationGenerator::with_config(42, config);
823        let (confs, _) = gen.generate_confirmations_with_balances(
824            &engagement,
825            &empty_workpapers(),
826            &accounts,
827            &balances,
828        );
829
830        assert!(!confs.is_empty());
831
832        // AR confirmations should have book_balance equal to the AR total (1,250,000).
833        let ar_confs: Vec<_> = confs
834            .iter()
835            .filter(|c| c.confirmation_type == ConfirmationType::AccountsReceivable)
836            .collect();
837        for conf in &ar_confs {
838            let expected = Decimal::from_f64(1_250_000.0).unwrap();
839            assert_eq!(
840                conf.book_balance, expected,
841                "AR confirmation should use real AR balance"
842            );
843        }
844
845        // Bank confirmations should use Cash balance (500,000).
846        let bank_confs: Vec<_> = confs
847            .iter()
848            .filter(|c| c.confirmation_type == ConfirmationType::BankBalance)
849            .collect();
850        for conf in &bank_confs {
851            let expected = Decimal::from_f64(500_000.0).unwrap();
852            assert_eq!(
853                conf.book_balance, expected,
854                "Bank confirmation should use real Cash balance"
855            );
856        }
857
858        // AP confirmations should use AP balance (875,000).
859        let ap_confs: Vec<_> = confs
860            .iter()
861            .filter(|c| c.confirmation_type == ConfirmationType::AccountsPayable)
862            .collect();
863        for conf in &ap_confs {
864            let expected = Decimal::from_f64(875_000.0).unwrap();
865            assert_eq!(
866                conf.book_balance, expected,
867                "AP confirmation should use real AP balance"
868            );
869        }
870    }
871
872    /// When balances are empty, the balance-weighted method falls back to synthetic values.
873    #[test]
874    fn test_balance_weighted_empty_balances_uses_synthetic() {
875        let engagement = create_test_engagement();
876        let accounts = vec!["1100".to_string()];
877        let empty_balances: HashMap<String, f64> = HashMap::new();
878
879        let mut gen = make_gen(42);
880        let (confs, _) = gen.generate_confirmations_with_balances(
881            &engagement,
882            &empty_workpapers(),
883            &accounts,
884            &empty_balances,
885        );
886
887        assert!(!confs.is_empty());
888        // All book balances should be in the synthetic $10k-$5M range.
889        for conf in &confs {
890            let bal = conf.book_balance;
891            assert!(
892                bal >= Decimal::new(10_000_00, 2) && bal <= Decimal::new(5_000_000_00, 2),
893                "expected synthetic balance in 10k-5M range, got {bal}"
894            );
895        }
896    }
897}