1use chrono::Duration;
8use datasynth_core::utils::seeded_rng;
9use rand::Rng;
10use rand_chacha::ChaCha8Rng;
11use rust_decimal::Decimal;
12
13use datasynth_core::models::audit::{
14 AuditEngagement, ConfirmationResponse, ConfirmationStatus, ConfirmationType,
15 ExternalConfirmation, RecipientType, ResponseType, Workpaper, WorkpaperSection,
16};
17
18#[derive(Debug, Clone)]
20pub struct ConfirmationGeneratorConfig {
21 pub confirmations_per_engagement: (u32, u32),
23 pub bank_balance_ratio: f64,
25 pub accounts_receivable_ratio: f64,
27 pub confirmed_response_ratio: f64,
29 pub exception_response_ratio: f64,
31 pub no_response_ratio: f64,
33 pub exception_reconciled_ratio: f64,
35}
36
37impl Default for ConfirmationGeneratorConfig {
38 fn default() -> Self {
39 Self {
40 confirmations_per_engagement: (5, 15),
41 bank_balance_ratio: 0.25,
42 accounts_receivable_ratio: 0.40,
43 confirmed_response_ratio: 0.70,
44 exception_response_ratio: 0.15,
45 no_response_ratio: 0.10,
46 exception_reconciled_ratio: 0.80,
47 }
48 }
49}
50
51pub struct ConfirmationGenerator {
53 rng: ChaCha8Rng,
55 config: ConfirmationGeneratorConfig,
57 confirmation_counter: u32,
59}
60
61impl ConfirmationGenerator {
62 pub fn new(seed: u64) -> Self {
64 Self {
65 rng: seeded_rng(seed, 0),
66 config: ConfirmationGeneratorConfig::default(),
67 confirmation_counter: 0,
68 }
69 }
70
71 pub fn with_config(seed: u64, config: ConfirmationGeneratorConfig) -> Self {
73 Self {
74 rng: seeded_rng(seed, 0),
75 config,
76 confirmation_counter: 0,
77 }
78 }
79
80 pub fn generate_confirmations(
95 &mut self,
96 engagement: &AuditEngagement,
97 workpapers: &[Workpaper],
98 account_codes: &[String],
99 ) -> (Vec<ExternalConfirmation>, Vec<ConfirmationResponse>) {
100 let count = self.rng.random_range(
101 self.config.confirmations_per_engagement.0..=self.config.confirmations_per_engagement.1,
102 ) as usize;
103
104 let substantive_wps: Vec<&Workpaper> = workpapers
106 .iter()
107 .filter(|wp| wp.section == WorkpaperSection::SubstantiveTesting)
108 .collect();
109
110 let mut confirmations = Vec::with_capacity(count);
111 let mut responses = Vec::with_capacity(count);
112
113 for i in 0..count {
114 let (conf_type, recipient_type, recipient_name) =
115 self.choose_confirmation_type(i, count);
116
117 let account_code: Option<String> = if account_codes.is_empty() {
119 None
120 } else {
121 let idx = self.rng.random_range(0..account_codes.len());
122 Some(account_codes[idx].clone())
123 };
124
125 let balance_units: i64 = self.rng.random_range(10_000_i64..=5_000_000_i64);
127 let book_balance = Decimal::new(balance_units * 100, 2); let confirmation_date = engagement.period_end_date;
131
132 let fieldwork_days = (engagement.fieldwork_end - engagement.fieldwork_start)
134 .num_days()
135 .max(1);
136 let sent_offset = self.rng.random_range(0..fieldwork_days);
137 let sent_date = engagement.fieldwork_start + Duration::days(sent_offset);
138 let deadline = sent_date + Duration::days(30);
139
140 self.confirmation_counter += 1;
141
142 let mut confirmation = ExternalConfirmation::new(
143 engagement.engagement_id,
144 conf_type,
145 &recipient_name,
146 recipient_type,
147 book_balance,
148 confirmation_date,
149 );
150
151 confirmation.confirmation_ref = format!(
153 "CONF-{}-{:04}",
154 engagement.fiscal_year, self.confirmation_counter
155 );
156
157 if !substantive_wps.is_empty() {
159 let wp_idx = self.rng.random_range(0..substantive_wps.len());
160 confirmation = confirmation.with_workpaper(substantive_wps[wp_idx].workpaper_id);
161 }
162
163 if let Some(ref code) = account_code {
165 confirmation = confirmation.with_account(code);
166 }
167
168 confirmation.send(sent_date, deadline);
170
171 let roll: f64 = self.rng.random();
173 let no_response_cutoff = self.config.no_response_ratio;
174 let exception_cutoff = no_response_cutoff + self.config.exception_response_ratio;
175 let confirmed_cutoff = exception_cutoff + self.config.confirmed_response_ratio;
176 if roll < no_response_cutoff {
179 confirmation.status = ConfirmationStatus::NoResponse;
181 } else {
182 let response_days = self.rng.random_range(5_i64..=25_i64);
184 let response_date = sent_date + Duration::days(response_days);
185
186 let response_type = if roll < exception_cutoff {
187 ResponseType::ConfirmedWithException
188 } else if roll < confirmed_cutoff {
189 ResponseType::Confirmed
190 } else {
191 ResponseType::Denied
192 };
193
194 let mut response = ConfirmationResponse::new(
195 confirmation.confirmation_id,
196 engagement.engagement_id,
197 response_date,
198 response_type,
199 );
200
201 match response_type {
202 ResponseType::Confirmed => {
203 response = response.with_confirmed_balance(book_balance);
205 confirmation.status = ConfirmationStatus::Completed;
206 }
207 ResponseType::ConfirmedWithException => {
208 let exception_pct: f64 = self.rng.random_range(0.01..0.08);
210 let exception_units = (balance_units as f64 * exception_pct).round() as i64;
211 let exception_amount = Decimal::new(exception_units.max(1) * 100, 2);
212 let confirmed_balance = book_balance - exception_amount;
213
214 response = response
215 .with_confirmed_balance(confirmed_balance)
216 .with_exception(
217 exception_amount,
218 self.exception_description(conf_type),
219 );
220
221 if self.rng.random::<f64>() < self.config.exception_reconciled_ratio {
223 response.reconcile(
224 "Difference investigated and reconciled to timing items \
225 — no audit adjustment required.",
226 );
227 }
228
229 confirmation.status = ConfirmationStatus::Completed;
230 }
231 ResponseType::Denied => {
232 confirmation.status = ConfirmationStatus::AlternativeProcedures;
234 }
235 ResponseType::NoReply => {
236 confirmation.status = ConfirmationStatus::NoResponse;
238 }
239 }
240
241 responses.push(response);
242 }
243
244 confirmations.push(confirmation);
245 }
246
247 (confirmations, responses)
248 }
249
250 fn choose_confirmation_type(
258 &mut self,
259 index: usize,
260 total: usize,
261 ) -> (ConfirmationType, RecipientType, String) {
262 let bank_cutoff = self.config.bank_balance_ratio;
264 let ar_cutoff = bank_cutoff + self.config.accounts_receivable_ratio;
265 let remaining = 1.0 - ar_cutoff;
267 let other_each = remaining / 6.0;
268
269 let fraction = (index as f64 + self.rng.random::<f64>()) / total.max(1) as f64;
271
272 if fraction < bank_cutoff {
273 let name = self.bank_name();
274 (ConfirmationType::BankBalance, RecipientType::Bank, name)
275 } else if fraction < ar_cutoff {
276 let name = self.customer_name();
277 (
278 ConfirmationType::AccountsReceivable,
279 RecipientType::Customer,
280 name,
281 )
282 } else if fraction < ar_cutoff + other_each {
283 let name = self.supplier_name();
284 (
285 ConfirmationType::AccountsPayable,
286 RecipientType::Supplier,
287 name,
288 )
289 } else if fraction < ar_cutoff + 2.0 * other_each {
290 let name = self.investment_firm_name();
291 (ConfirmationType::Investment, RecipientType::Other, name)
292 } else if fraction < ar_cutoff + 3.0 * other_each {
293 let name = self.bank_name();
294 (ConfirmationType::Loan, RecipientType::Bank, name)
295 } else if fraction < ar_cutoff + 4.0 * other_each {
296 let name = self.legal_firm_name();
297 (ConfirmationType::Legal, RecipientType::LegalCounsel, name)
298 } else if fraction < ar_cutoff + 5.0 * other_each {
299 let name = self.insurer_name();
300 (ConfirmationType::Insurance, RecipientType::Insurer, name)
301 } else {
302 let name = self.supplier_name();
303 (ConfirmationType::Inventory, RecipientType::Other, name)
304 }
305 }
306
307 fn bank_name(&mut self) -> String {
308 let banks = [
309 "First National Bank",
310 "City Commerce Bank",
311 "Meridian Federal Credit Union",
312 "Pacific Trust Bank",
313 "Atlantic Financial Corp",
314 "Heritage Savings Bank",
315 "Sunrise Bank plc",
316 "Continental Banking Group",
317 ];
318 let idx = self.rng.random_range(0..banks.len());
319 banks[idx].to_string()
320 }
321
322 fn customer_name(&mut self) -> String {
323 let names = [
324 "Acme Industries Ltd",
325 "Beacon Holdings PLC",
326 "Crestwood Manufacturing",
327 "Delta Retail Group",
328 "Epsilon Logistics Inc",
329 "Falcon Distribution SA",
330 "Global Supplies Corp",
331 "Horizon Trading Ltd",
332 "Irongate Wholesale",
333 "Jupiter Services LLC",
334 ];
335 let idx = self.rng.random_range(0..names.len());
336 names[idx].to_string()
337 }
338
339 fn supplier_name(&mut self) -> String {
340 let names = [
341 "Allied Components GmbH",
342 "BestSource Procurement",
343 "Cornerstone Supplies",
344 "Direct Parts Ltd",
345 "Eagle Procurement SA",
346 "Foundation Materials Inc",
347 "Granite Supply Co",
348 ];
349 let idx = self.rng.random_range(0..names.len());
350 names[idx].to_string()
351 }
352
353 fn investment_firm_name(&mut self) -> String {
354 let names = [
355 "Summit Asset Management",
356 "Veritas Capital Partners",
357 "Pinnacle Investment Trust",
358 "Apex Securities Ltd",
359 ];
360 let idx = self.rng.random_range(0..names.len());
361 names[idx].to_string()
362 }
363
364 fn legal_firm_name(&mut self) -> String {
365 let names = [
366 "Harrison & Webb LLP",
367 "Morrison Clarke Solicitors",
368 "Pemberton Legal Group",
369 "Sterling Advocates LLP",
370 ];
371 let idx = self.rng.random_range(0..names.len());
372 names[idx].to_string()
373 }
374
375 fn insurer_name(&mut self) -> String {
376 let names = [
377 "Centennial Insurance Co",
378 "Landmark Re Ltd",
379 "Prudential Assurance PLC",
380 "Shield Underwriters Ltd",
381 ];
382 let idx = self.rng.random_range(0..names.len());
383 names[idx].to_string()
384 }
385
386 fn exception_description(&self, conf_type: ConfirmationType) -> &'static str {
387 match conf_type {
388 ConfirmationType::BankBalance => {
389 "Outstanding cheque issued before year-end not yet presented for clearing"
390 }
391 ConfirmationType::AccountsReceivable => {
392 "Credit note raised before period end not yet reflected in client ledger"
393 }
394 ConfirmationType::AccountsPayable => {
395 "Goods received before year-end; supplier invoice recorded in following period"
396 }
397 ConfirmationType::Investment => {
398 "Accrued income on securities differs due to day-count convention"
399 }
400 ConfirmationType::Loan => {
401 "Accrued interest calculation basis differs from bank statement"
402 }
403 ConfirmationType::Legal => {
404 "Matter description differs from client disclosure — wording to be aligned"
405 }
406 ConfirmationType::Insurance => {
407 "Policy premium allocation differs by one month due to renewal date"
408 }
409 ConfirmationType::Inventory => {
410 "Consignment stock included in third-party count but excluded from client records"
411 }
412 }
413 }
414}
415
416#[cfg(test)]
421#[allow(clippy::unwrap_used)]
422mod tests {
423 use super::*;
424 use crate::audit::test_helpers::create_test_engagement;
425
426 fn make_gen(seed: u64) -> ConfirmationGenerator {
427 ConfirmationGenerator::new(seed)
428 }
429
430 fn empty_workpapers() -> Vec<Workpaper> {
431 Vec::new()
432 }
433
434 fn empty_accounts() -> Vec<String> {
435 Vec::new()
436 }
437
438 #[test]
442 fn test_generates_expected_count() {
443 let engagement = create_test_engagement();
444 let mut gen = make_gen(42);
445 let (confs, _) =
446 gen.generate_confirmations(&engagement, &empty_workpapers(), &empty_accounts());
447
448 let min = ConfirmationGeneratorConfig::default()
449 .confirmations_per_engagement
450 .0 as usize;
451 let max = ConfirmationGeneratorConfig::default()
452 .confirmations_per_engagement
453 .1 as usize;
454 assert!(
455 confs.len() >= min && confs.len() <= max,
456 "expected {min}..={max}, got {}",
457 confs.len()
458 );
459 }
460
461 #[test]
463 fn test_response_distribution() {
464 let engagement = create_test_engagement();
465 let config = ConfirmationGeneratorConfig {
467 confirmations_per_engagement: (100, 100),
468 ..Default::default()
469 };
470 let mut gen = ConfirmationGenerator::with_config(99, config);
471 let (confs, responses) =
472 gen.generate_confirmations(&engagement, &empty_workpapers(), &empty_accounts());
473
474 let total = confs.len() as f64;
475 let confirmed_count = responses
476 .iter()
477 .filter(|r| r.response_type == ResponseType::Confirmed)
478 .count() as f64;
479
480 let ratio = confirmed_count / total;
482 assert!(
483 (0.55..=0.85).contains(&ratio),
484 "confirmed ratio {ratio:.2} outside expected 55–85%"
485 );
486 }
487
488 #[test]
490 fn test_exception_amounts() {
491 let engagement = create_test_engagement();
492 let config = ConfirmationGeneratorConfig {
493 confirmations_per_engagement: (200, 200),
494 exception_response_ratio: 0.50, confirmed_response_ratio: 0.40,
496 no_response_ratio: 0.05,
497 ..Default::default()
498 };
499 let mut gen = ConfirmationGenerator::with_config(77, config);
500 let (confs, responses) =
501 gen.generate_confirmations(&engagement, &empty_workpapers(), &empty_accounts());
502
503 let book_map: std::collections::HashMap<uuid::Uuid, Decimal> = confs
505 .iter()
506 .map(|c| (c.confirmation_id, c.book_balance))
507 .collect();
508
509 let exceptions: Vec<&ConfirmationResponse> =
510 responses.iter().filter(|r| r.has_exception).collect();
511
512 assert!(
513 !exceptions.is_empty(),
514 "expected at least some exception responses"
515 );
516
517 for resp in &exceptions {
518 let book = *book_map.get(&resp.confirmation_id).unwrap();
519 let exc = resp.exception_amount.unwrap();
520 let ratio = (exc / book).to_string().parse::<f64>().unwrap_or(1.0);
522 assert!(
523 ratio > 0.0 && ratio <= 0.09,
524 "exception ratio {ratio:.4} out of expected 0–9% for book={book}, exc={exc}"
525 );
526 }
527 }
528
529 #[test]
531 fn test_deterministic_with_seed() {
532 let engagement = create_test_engagement();
533 let accounts = vec!["1010".to_string(), "1200".to_string(), "2100".to_string()];
534
535 let (confs_a, resp_a) = {
536 let mut gen = make_gen(1234);
537 gen.generate_confirmations(&engagement, &empty_workpapers(), &accounts)
538 };
539 let (confs_b, resp_b) = {
540 let mut gen = make_gen(1234);
541 gen.generate_confirmations(&engagement, &empty_workpapers(), &accounts)
542 };
543
544 assert_eq!(
545 confs_a.len(),
546 confs_b.len(),
547 "confirmation counts differ across identical seeds"
548 );
549 assert_eq!(
550 resp_a.len(),
551 resp_b.len(),
552 "response counts differ across identical seeds"
553 );
554
555 for (a, b) in confs_a.iter().zip(confs_b.iter()) {
556 assert_eq!(a.confirmation_ref, b.confirmation_ref);
557 assert_eq!(a.book_balance, b.book_balance);
558 assert_eq!(a.status, b.status);
559 assert_eq!(a.confirmation_type, b.confirmation_type);
560 }
561 }
562
563 #[test]
565 fn test_account_codes_linked() {
566 let engagement = create_test_engagement();
567 let accounts = vec!["ACC-001".to_string(), "ACC-002".to_string()];
568 let mut gen = make_gen(55);
569 let (confs, _) = gen.generate_confirmations(&engagement, &empty_workpapers(), &accounts);
570
571 for conf in &confs {
573 assert!(
574 conf.account_id.as_deref().is_some(),
575 "confirmation {} should have an account_id",
576 conf.confirmation_ref
577 );
578 assert!(
579 accounts.contains(conf.account_id.as_ref().unwrap()),
580 "account_id '{}' not in provided list",
581 conf.account_id.as_ref().unwrap()
582 );
583 }
584 }
585
586 #[test]
588 fn test_workpaper_linking() {
589 use datasynth_core::models::audit::WorkpaperSection;
590
591 let engagement = create_test_engagement();
592 let wp = Workpaper::new(
594 engagement.engagement_id,
595 "D-001",
596 "Test Workpaper",
597 WorkpaperSection::SubstantiveTesting,
598 );
599 let wp_id = wp.workpaper_id;
600
601 let mut gen = make_gen(71);
602 let (confs, _) = gen.generate_confirmations(&engagement, &[wp], &empty_accounts());
603
604 for conf in &confs {
606 assert_eq!(
607 conf.workpaper_id,
608 Some(wp_id),
609 "confirmation {} should link to workpaper {wp_id}",
610 conf.confirmation_ref
611 );
612 }
613 }
614}