1use 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#[derive(Debug, Clone)]
23pub struct ConfirmationGeneratorConfig {
24 pub confirmations_per_engagement: (u32, u32),
26 pub bank_balance_ratio: f64,
28 pub accounts_receivable_ratio: f64,
30 pub confirmed_response_ratio: f64,
32 pub exception_response_ratio: f64,
34 pub no_response_ratio: f64,
36 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
54pub struct ConfirmationGenerator {
56 rng: ChaCha8Rng,
58 config: ConfirmationGeneratorConfig,
60 confirmation_counter: u32,
62}
63
64impl ConfirmationGenerator {
65 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 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 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 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 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 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); let confirmation_date = engagement.period_end_date;
134
135 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 confirmation.confirmation_ref = format!(
156 "CONF-{}-{:04}",
157 engagement.fiscal_year, self.confirmation_counter
158 );
159
160 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 if let Some(ref code) = account_code {
168 confirmation = confirmation.with_account(code);
169 }
170
171 confirmation.send(sent_date, deadline);
173
174 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 if roll < no_response_cutoff {
182 confirmation.status = ConfirmationStatus::NoResponse;
184 } else {
185 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 response = response.with_confirmed_balance(book_balance);
208 confirmation.status = ConfirmationStatus::Completed;
209 }
210 ResponseType::ConfirmedWithException => {
211 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 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 confirmation.status = ConfirmationStatus::AlternativeProcedures;
237 }
238 ResponseType::NoReply => {
239 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 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 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 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 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 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 fn choose_confirmation_type(
448 &mut self,
449 index: usize,
450 total: usize,
451 ) -> (ConfirmationType, RecipientType, String) {
452 let bank_cutoff = self.config.bank_balance_ratio;
454 let ar_cutoff = bank_cutoff + self.config.accounts_receivable_ratio;
455 let remaining = 1.0 - ar_cutoff;
457 let other_each = remaining / 6.0;
458
459 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#[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 #[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 #[test]
653 fn test_response_distribution() {
654 let engagement = create_test_engagement();
655 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 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 #[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, 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 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 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 #[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 #[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 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 #[test]
778 fn test_workpaper_linking() {
779 use datasynth_core::models::audit::WorkpaperSection;
780
781 let engagement = create_test_engagement();
782 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 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 #[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), ("2000".into(), 875_000.0), ("1010".into(), 500_000.0), ]);
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 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 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 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 #[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 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}