1use chrono::NaiveDate;
9use datasynth_core::utils::seeded_rng;
10use rand::Rng;
11use rand_chacha::ChaCha8Rng;
12use rust_decimal::Decimal;
13use uuid::Uuid;
14
15use datasynth_standards::audit::confirmation::{
16 AlternativeProcedureConclusion, AlternativeProcedureReason, AlternativeProcedures,
17 ConfirmationConclusion, ConfirmationForm, ConfirmationReconciliation, ConfirmationResponse,
18 ConfirmationResponseStatus, ConfirmationType, ExternalConfirmation, ReconcilingItem,
19 ReconcilingItemType, ResponseReliability,
20};
21
22#[derive(Debug, Clone)]
24pub struct ConfirmationGeneratorConfig {
25 pub confirmation_count: usize,
27 pub positive_response_rate: f64,
29 pub exception_rate: f64,
31 pub non_response_rate: f64,
33 pub type_weights: [f64; 4],
35}
36
37impl Default for ConfirmationGeneratorConfig {
38 fn default() -> Self {
39 Self {
40 confirmation_count: 50,
41 positive_response_rate: 0.85,
42 exception_rate: 0.10,
43 non_response_rate: 0.10,
44 type_weights: [0.40, 0.30, 0.20, 0.10],
45 }
46 }
47}
48
49pub struct ConfirmationGenerator {
51 rng: ChaCha8Rng,
52 config: ConfirmationGeneratorConfig,
53 confirmation_counter: usize,
54}
55
56const SEED_DISCRIMINATOR: u64 = 0xAE_0E;
59
60impl ConfirmationGenerator {
61 pub fn new(seed: u64) -> Self {
63 Self {
64 rng: seeded_rng(seed, SEED_DISCRIMINATOR),
65 config: ConfirmationGeneratorConfig::default(),
66 confirmation_counter: 0,
67 }
68 }
69
70 pub fn with_config(seed: u64, config: ConfirmationGeneratorConfig) -> Self {
72 Self {
73 rng: seeded_rng(seed, SEED_DISCRIMINATOR),
74 config,
75 confirmation_counter: 0,
76 }
77 }
78
79 pub fn generate_confirmations(
84 &mut self,
85 engagement_id: Uuid,
86 base_date: NaiveDate,
87 ) -> Vec<ExternalConfirmation> {
88 let count = self.config.confirmation_count;
89 let mut confirmations = Vec::with_capacity(count);
90
91 for _ in 0..count {
92 self.confirmation_counter += 1;
93 let confirmation = self.build_confirmation(engagement_id, base_date);
94 confirmations.push(confirmation);
95 }
96
97 confirmations
98 }
99
100 fn pick_confirmation_type(&mut self) -> ConfirmationType {
102 let weights = &self.config.type_weights;
103 let total: f64 = weights.iter().sum();
104 let mut r: f64 = self.rng.random_range(0.0..total);
105
106 for (i, &w) in weights.iter().enumerate() {
107 r -= w;
108 if r <= 0.0 {
109 return match i {
110 0 => ConfirmationType::AccountsReceivable,
111 1 => ConfirmationType::AccountsPayable,
112 2 => ConfirmationType::Bank,
113 _ => ConfirmationType::Legal,
114 };
115 }
116 }
117 ConfirmationType::AccountsReceivable
118 }
119
120 fn generate_confirmee_name(&mut self, conf_type: ConfirmationType) -> String {
122 let n = self.confirmation_counter;
123 match conf_type {
124 ConfirmationType::AccountsReceivable => format!("Customer-{}", n),
125 ConfirmationType::AccountsPayable => format!("Vendor-{}", n),
126 ConfirmationType::Bank => {
127 let cities = ["New York", "London", "Chicago", "Dallas", "Boston"];
128 let idx = self.rng.random_range(0..cities.len());
129 format!("Bank of {}", cities[idx])
130 }
131 ConfirmationType::Legal => format!("Law Office {}", n),
132 _ => format!("Confirmee-{}", n),
133 }
134 }
135
136 fn generate_client_amount(&mut self, conf_type: ConfirmationType) -> Decimal {
138 match conf_type {
139 ConfirmationType::AccountsReceivable | ConfirmationType::AccountsPayable => {
140 Decimal::from(self.rng.random_range(1000..500_000_i64))
141 }
142 ConfirmationType::Bank => Decimal::from(self.rng.random_range(50_000..5_000_000_i64)),
143 ConfirmationType::Legal => Decimal::from(self.rng.random_range(500..100_000_i64)),
144 _ => Decimal::from(self.rng.random_range(1000..500_000_i64)),
145 }
146 }
147
148 fn generate_item_description(&self, conf_type: ConfirmationType) -> String {
150 match conf_type {
151 ConfirmationType::AccountsReceivable => "Trade receivable balance".to_string(),
152 ConfirmationType::AccountsPayable => "Trade payable balance".to_string(),
153 ConfirmationType::Bank => "Bank account balance".to_string(),
154 ConfirmationType::Legal => "Legal matters and contingencies".to_string(),
155 _ => "Account balance".to_string(),
156 }
157 }
158
159 fn build_confirmation(
161 &mut self,
162 engagement_id: Uuid,
163 base_date: NaiveDate,
164 ) -> ExternalConfirmation {
165 let conf_type = self.pick_confirmation_type();
166 let confirmee_name = self.generate_confirmee_name(conf_type);
167 let client_amount = self.generate_client_amount(conf_type);
168 let item_description = self.generate_item_description(conf_type);
169
170 let days_offset = self.rng.random_range(0..14_i64);
171 let date_sent = base_date + chrono::Duration::days(days_offset);
172
173 let mut confirmation = ExternalConfirmation::new(
174 engagement_id,
175 conf_type,
176 &confirmee_name,
177 &item_description,
178 client_amount,
179 "USD",
180 );
181
182 confirmation.confirmation_form = ConfirmationForm::Positive;
183 confirmation.date_sent = date_sent;
184 confirmation.prepared_by = format!("Audit Staff {}", self.confirmation_counter);
185 confirmation.workpaper_reference =
186 Some(format!("WP-CONF-{:04}", self.confirmation_counter));
187
188 let roll: f64 = self.rng.random_range(0.0..1.0);
190
191 if roll < self.config.non_response_rate {
192 self.apply_no_response(&mut confirmation, date_sent);
194 } else {
195 let remaining_roll: f64 = self.rng.random_range(0.0..1.0);
197
198 if remaining_roll < self.config.positive_response_rate {
199 self.apply_received_agrees(&mut confirmation, date_sent, client_amount);
200 } else if remaining_roll
201 < self.config.positive_response_rate + self.config.exception_rate
202 {
203 self.apply_received_disagrees(&mut confirmation, date_sent, client_amount);
204 } else {
205 self.apply_received_partial(&mut confirmation, date_sent, client_amount);
206 }
207 }
208
209 if matches!(
211 confirmation.response_status,
212 ConfirmationResponseStatus::Pending | ConfirmationResponseStatus::NoResponse
213 ) {
214 confirmation.follow_up_date = Some(date_sent + chrono::Duration::days(14));
215 }
216
217 confirmation
218 }
219
220 fn apply_received_agrees(
222 &mut self,
223 confirmation: &mut ExternalConfirmation,
224 date_sent: NaiveDate,
225 client_amount: Decimal,
226 ) {
227 let response_days = self.rng.random_range(7..30_i64);
228 let date_received = date_sent + chrono::Duration::days(response_days);
229
230 let mut response = ConfirmationResponse::new(date_received, client_amount, true);
231 response.respondent_name = format!("{} - Authorized Signer", confirmation.confirmee_name);
232 response.appears_authentic = true;
233 response.reliability_assessment = ResponseReliability::Reliable;
234
235 confirmation.response_status = ConfirmationResponseStatus::ReceivedAgrees;
236 confirmation.response = Some(response);
237 confirmation.conclusion = ConfirmationConclusion::Confirmed;
238 }
239
240 fn apply_received_disagrees(
242 &mut self,
243 confirmation: &mut ExternalConfirmation,
244 date_sent: NaiveDate,
245 client_amount: Decimal,
246 ) {
247 let response_days = self.rng.random_range(7..30_i64);
248 let date_received = date_sent + chrono::Duration::days(response_days);
249
250 let factor: f64 = self.rng.random_range(0.90..1.10);
252 let factor_decimal = Decimal::from_f64_retain(factor).unwrap_or(Decimal::ONE);
253 let confirmed_amount = client_amount * factor_decimal;
254
255 let mut response = ConfirmationResponse::new(date_received, confirmed_amount, false);
256 response.respondent_name = format!("{} - Authorized Signer", confirmation.confirmee_name);
257 response.appears_authentic = true;
258 response.reliability_assessment = ResponseReliability::Reliable;
259
260 confirmation.response_status = ConfirmationResponseStatus::ReceivedDisagrees;
261 confirmation.response = Some(response);
262
263 let mut reconciliation = ConfirmationReconciliation::new(client_amount, confirmed_amount);
265
266 let item_type = if self.rng.random_bool(0.5) {
268 ReconcilingItemType::CashInTransit
269 } else {
270 ReconcilingItemType::CutoffAdjustment
271 };
272
273 let difference = client_amount - confirmed_amount;
274 let item = ReconcilingItem {
275 description: match item_type {
276 ReconcilingItemType::CashInTransit => "Payment in transit".to_string(),
277 ReconcilingItemType::CutoffAdjustment => "Cutoff timing difference".to_string(),
278 _ => "Other reconciling item".to_string(),
279 },
280 amount: difference,
281 item_type,
282 evidence: "Examined supporting documentation".to_string(),
283 };
284 reconciliation.add_reconciling_item(item);
285
286 confirmation.reconciliation = Some(reconciliation);
287
288 if self.rng.random_bool(0.80) {
290 confirmation.conclusion = ConfirmationConclusion::ExceptionResolved;
291 } else {
292 confirmation.conclusion = ConfirmationConclusion::PotentialMisstatement;
293 }
294 }
295
296 fn apply_no_response(&mut self, confirmation: &mut ExternalConfirmation, date_sent: NaiveDate) {
298 confirmation.response_status = ConfirmationResponseStatus::NoResponse;
299 confirmation.follow_up_date = Some(date_sent + chrono::Duration::days(14));
300
301 let mut alt_procedures = AlternativeProcedures::new(AlternativeProcedureReason::NoResponse);
302 alt_procedures
303 .evidence_obtained
304 .push("Reviewed subsequent transactions".to_string());
305 alt_procedures
306 .evidence_obtained
307 .push("Examined supporting documentation".to_string());
308
309 if self.rng.random_bool(0.90) {
311 alt_procedures.conclusion = AlternativeProcedureConclusion::SufficientEvidence;
312 confirmation.conclusion = ConfirmationConclusion::AlternativesSatisfactory;
313 } else {
314 alt_procedures.conclusion = AlternativeProcedureConclusion::InsufficientEvidence;
315 confirmation.conclusion = ConfirmationConclusion::InsufficientEvidence;
316 }
317
318 confirmation.alternative_procedures = Some(alt_procedures);
319 }
320
321 fn apply_received_partial(
323 &mut self,
324 confirmation: &mut ExternalConfirmation,
325 date_sent: NaiveDate,
326 client_amount: Decimal,
327 ) {
328 let response_days = self.rng.random_range(7..30_i64);
329 let date_received = date_sent + chrono::Duration::days(response_days);
330
331 let partial_factor: f64 = self.rng.random_range(0.50..0.90);
333 let partial_decimal =
334 Decimal::from_f64_retain(partial_factor).unwrap_or(Decimal::new(70, 2));
335 let partial_amount = client_amount * partial_decimal;
336
337 let mut response = ConfirmationResponse::new(date_received, partial_amount, false);
338 response.respondent_name = format!("{} - Authorized Signer", confirmation.confirmee_name);
339 response.appears_authentic = true;
340 response.reliability_assessment = ResponseReliability::Reliable;
341 response.comments = "Partial information provided".to_string();
342
343 confirmation.response_status = ConfirmationResponseStatus::ReceivedPartial;
344 confirmation.response = Some(response);
345 confirmation.conclusion = ConfirmationConclusion::ExceptionResolved;
346 }
347}
348
349#[cfg(test)]
350#[allow(clippy::unwrap_used)]
351mod tests {
352 use super::*;
353
354 #[test]
355 fn test_deterministic_generation() {
356 let engagement_id = Uuid::nil();
357 let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
358
359 let mut gen1 = ConfirmationGenerator::new(42);
360 let mut gen2 = ConfirmationGenerator::new(42);
361
362 let results1 = gen1.generate_confirmations(engagement_id, base_date);
363 let results2 = gen2.generate_confirmations(engagement_id, base_date);
364
365 assert_eq!(results1.len(), results2.len());
366 for (c1, c2) in results1.iter().zip(results2.iter()) {
367 assert_eq!(c1.confirmee_name, c2.confirmee_name);
368 assert_eq!(c1.client_amount, c2.client_amount);
369 assert_eq!(c1.response_status, c2.response_status);
370 assert_eq!(c1.date_sent, c2.date_sent);
371 assert_eq!(c1.prepared_by, c2.prepared_by);
372 }
373 }
374
375 #[test]
376 fn test_confirmation_count() {
377 let engagement_id = Uuid::nil();
378 let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
379
380 let config = ConfirmationGeneratorConfig {
381 confirmation_count: 25,
382 ..Default::default()
383 };
384 let mut gen = ConfirmationGenerator::with_config(42, config);
385 let results = gen.generate_confirmations(engagement_id, base_date);
386
387 assert_eq!(results.len(), 25);
388 }
389
390 #[test]
391 fn test_type_distribution() {
392 let engagement_id = Uuid::nil();
393 let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
394
395 let config = ConfirmationGeneratorConfig {
396 confirmation_count: 200,
397 ..Default::default()
398 };
399 let mut gen = ConfirmationGenerator::with_config(42, config);
400 let results = gen.generate_confirmations(engagement_id, base_date);
401
402 let ar_count = results
403 .iter()
404 .filter(|c| c.confirmation_type == ConfirmationType::AccountsReceivable)
405 .count();
406 let ap_count = results
407 .iter()
408 .filter(|c| c.confirmation_type == ConfirmationType::AccountsPayable)
409 .count();
410 let bank_count = results
411 .iter()
412 .filter(|c| c.confirmation_type == ConfirmationType::Bank)
413 .count();
414 let legal_count = results
415 .iter()
416 .filter(|c| c.confirmation_type == ConfirmationType::Legal)
417 .count();
418
419 assert!(
421 ar_count > ap_count,
422 "AR ({}) should exceed AP ({})",
423 ar_count,
424 ap_count
425 );
426 assert!(
427 ap_count > bank_count,
428 "AP ({}) should exceed Bank ({})",
429 ap_count,
430 bank_count
431 );
432 assert!(
433 bank_count > legal_count,
434 "Bank ({}) should exceed Legal ({})",
435 bank_count,
436 legal_count
437 );
438 }
439
440 #[test]
441 fn test_positive_response_rate() {
442 let engagement_id = Uuid::nil();
443 let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
444
445 let config = ConfirmationGeneratorConfig {
446 confirmation_count: 100,
447 positive_response_rate: 1.0,
448 exception_rate: 0.0,
449 non_response_rate: 0.0,
450 type_weights: [1.0, 0.0, 0.0, 0.0],
451 };
452 let mut gen = ConfirmationGenerator::with_config(42, config);
453 let results = gen.generate_confirmations(engagement_id, base_date);
454
455 for c in &results {
456 assert_eq!(
457 c.response_status,
458 ConfirmationResponseStatus::ReceivedAgrees,
459 "All should be ReceivedAgrees when positive_response_rate=1.0"
460 );
461 }
462 }
463
464 #[test]
465 fn test_non_response_generates_alternatives() {
466 let engagement_id = Uuid::nil();
467 let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
468
469 let config = ConfirmationGeneratorConfig {
470 confirmation_count: 100,
471 positive_response_rate: 0.0,
472 exception_rate: 0.0,
473 non_response_rate: 1.0,
474 type_weights: [1.0, 0.0, 0.0, 0.0],
475 };
476 let mut gen = ConfirmationGenerator::with_config(42, config);
477 let results = gen.generate_confirmations(engagement_id, base_date);
478
479 for c in &results {
480 assert_eq!(c.response_status, ConfirmationResponseStatus::NoResponse);
481 assert!(
482 c.alternative_procedures.is_some(),
483 "NoResponse confirmations must have alternative_procedures"
484 );
485 }
486 }
487
488 #[test]
489 fn test_disagreements_have_reconciliation() {
490 let engagement_id = Uuid::nil();
491 let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
492
493 let config = ConfirmationGeneratorConfig {
496 confirmation_count: 50,
497 positive_response_rate: 0.0,
498 exception_rate: 1.0,
499 non_response_rate: 0.0,
500 type_weights: [1.0, 0.0, 0.0, 0.0],
501 };
502 let mut gen = ConfirmationGenerator::with_config(42, config);
503 let results = gen.generate_confirmations(engagement_id, base_date);
504
505 let disagrees: Vec<_> = results
506 .iter()
507 .filter(|c| c.response_status == ConfirmationResponseStatus::ReceivedDisagrees)
508 .collect();
509
510 assert!(!disagrees.is_empty(), "Should have some disagreements");
511
512 for c in &disagrees {
513 assert!(
514 c.reconciliation.is_some(),
515 "ReceivedDisagrees confirmations must have reconciliation"
516 );
517 }
518 }
519
520 #[test]
521 fn test_all_confirmations_have_prepared_by() {
522 let engagement_id = Uuid::nil();
523 let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
524
525 let mut gen = ConfirmationGenerator::new(42);
526 let results = gen.generate_confirmations(engagement_id, base_date);
527
528 for c in &results {
529 assert!(
530 !c.prepared_by.is_empty(),
531 "All confirmations must have non-empty prepared_by"
532 );
533 }
534 }
535
536 #[test]
537 fn test_zero_non_response_rate() {
538 let engagement_id = Uuid::nil();
539 let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
540
541 let config = ConfirmationGeneratorConfig {
542 confirmation_count: 100,
543 positive_response_rate: 0.85,
544 exception_rate: 0.10,
545 non_response_rate: 0.0,
546 type_weights: [0.40, 0.30, 0.20, 0.10],
547 };
548 let mut gen = ConfirmationGenerator::with_config(42, config);
549 let results = gen.generate_confirmations(engagement_id, base_date);
550
551 let no_responses = results
552 .iter()
553 .filter(|c| c.response_status == ConfirmationResponseStatus::NoResponse)
554 .count();
555
556 assert_eq!(
557 no_responses, 0,
558 "With non_response_rate=0.0, there should be no NoResponse statuses"
559 );
560 }
561}