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)]
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 #[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 #[test]
652 fn test_response_distribution() {
653 let engagement = create_test_engagement();
654 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 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 #[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, 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 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 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 #[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 #[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 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 #[test]
777 fn test_workpaper_linking() {
778 use datasynth_core::models::audit::WorkpaperSection;
779
780 let engagement = create_test_engagement();
781 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 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 #[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), ("2000".into(), 875_000.0), ("1010".into(), 500_000.0), ]);
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 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 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 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 #[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 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}