1use chrono::NaiveDate;
9use datasynth_core::utils::seeded_rng;
10use rand::RngExt;
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)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn test_deterministic_generation() {
355 let engagement_id = Uuid::nil();
356 let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
357
358 let mut gen1 = ConfirmationGenerator::new(42);
359 let mut gen2 = ConfirmationGenerator::new(42);
360
361 let results1 = gen1.generate_confirmations(engagement_id, base_date);
362 let results2 = gen2.generate_confirmations(engagement_id, base_date);
363
364 assert_eq!(results1.len(), results2.len());
365 for (c1, c2) in results1.iter().zip(results2.iter()) {
366 assert_eq!(c1.confirmee_name, c2.confirmee_name);
367 assert_eq!(c1.client_amount, c2.client_amount);
368 assert_eq!(c1.response_status, c2.response_status);
369 assert_eq!(c1.date_sent, c2.date_sent);
370 assert_eq!(c1.prepared_by, c2.prepared_by);
371 }
372 }
373
374 #[test]
375 fn test_confirmation_count() {
376 let engagement_id = Uuid::nil();
377 let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
378
379 let config = ConfirmationGeneratorConfig {
380 confirmation_count: 25,
381 ..Default::default()
382 };
383 let mut gen = ConfirmationGenerator::with_config(42, config);
384 let results = gen.generate_confirmations(engagement_id, base_date);
385
386 assert_eq!(results.len(), 25);
387 }
388
389 #[test]
390 fn test_type_distribution() {
391 let engagement_id = Uuid::nil();
392 let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
393
394 let config = ConfirmationGeneratorConfig {
395 confirmation_count: 200,
396 ..Default::default()
397 };
398 let mut gen = ConfirmationGenerator::with_config(42, config);
399 let results = gen.generate_confirmations(engagement_id, base_date);
400
401 let ar_count = results
402 .iter()
403 .filter(|c| c.confirmation_type == ConfirmationType::AccountsReceivable)
404 .count();
405 let ap_count = results
406 .iter()
407 .filter(|c| c.confirmation_type == ConfirmationType::AccountsPayable)
408 .count();
409 let bank_count = results
410 .iter()
411 .filter(|c| c.confirmation_type == ConfirmationType::Bank)
412 .count();
413 let legal_count = results
414 .iter()
415 .filter(|c| c.confirmation_type == ConfirmationType::Legal)
416 .count();
417
418 assert!(
420 ar_count > ap_count,
421 "AR ({}) should exceed AP ({})",
422 ar_count,
423 ap_count
424 );
425 assert!(
426 ap_count > bank_count,
427 "AP ({}) should exceed Bank ({})",
428 ap_count,
429 bank_count
430 );
431 assert!(
432 bank_count > legal_count,
433 "Bank ({}) should exceed Legal ({})",
434 bank_count,
435 legal_count
436 );
437 }
438
439 #[test]
440 fn test_positive_response_rate() {
441 let engagement_id = Uuid::nil();
442 let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
443
444 let config = ConfirmationGeneratorConfig {
445 confirmation_count: 100,
446 positive_response_rate: 1.0,
447 exception_rate: 0.0,
448 non_response_rate: 0.0,
449 type_weights: [1.0, 0.0, 0.0, 0.0],
450 };
451 let mut gen = ConfirmationGenerator::with_config(42, config);
452 let results = gen.generate_confirmations(engagement_id, base_date);
453
454 for c in &results {
455 assert_eq!(
456 c.response_status,
457 ConfirmationResponseStatus::ReceivedAgrees,
458 "All should be ReceivedAgrees when positive_response_rate=1.0"
459 );
460 }
461 }
462
463 #[test]
464 fn test_non_response_generates_alternatives() {
465 let engagement_id = Uuid::nil();
466 let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
467
468 let config = ConfirmationGeneratorConfig {
469 confirmation_count: 100,
470 positive_response_rate: 0.0,
471 exception_rate: 0.0,
472 non_response_rate: 1.0,
473 type_weights: [1.0, 0.0, 0.0, 0.0],
474 };
475 let mut gen = ConfirmationGenerator::with_config(42, config);
476 let results = gen.generate_confirmations(engagement_id, base_date);
477
478 for c in &results {
479 assert_eq!(c.response_status, ConfirmationResponseStatus::NoResponse);
480 assert!(
481 c.alternative_procedures.is_some(),
482 "NoResponse confirmations must have alternative_procedures"
483 );
484 }
485 }
486
487 #[test]
488 fn test_disagreements_have_reconciliation() {
489 let engagement_id = Uuid::nil();
490 let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
491
492 let config = ConfirmationGeneratorConfig {
495 confirmation_count: 50,
496 positive_response_rate: 0.0,
497 exception_rate: 1.0,
498 non_response_rate: 0.0,
499 type_weights: [1.0, 0.0, 0.0, 0.0],
500 };
501 let mut gen = ConfirmationGenerator::with_config(42, config);
502 let results = gen.generate_confirmations(engagement_id, base_date);
503
504 let disagrees: Vec<_> = results
505 .iter()
506 .filter(|c| c.response_status == ConfirmationResponseStatus::ReceivedDisagrees)
507 .collect();
508
509 assert!(!disagrees.is_empty(), "Should have some disagreements");
510
511 for c in &disagrees {
512 assert!(
513 c.reconciliation.is_some(),
514 "ReceivedDisagrees confirmations must have reconciliation"
515 );
516 }
517 }
518
519 #[test]
520 fn test_all_confirmations_have_prepared_by() {
521 let engagement_id = Uuid::nil();
522 let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
523
524 let mut gen = ConfirmationGenerator::new(42);
525 let results = gen.generate_confirmations(engagement_id, base_date);
526
527 for c in &results {
528 assert!(
529 !c.prepared_by.is_empty(),
530 "All confirmations must have non-empty prepared_by"
531 );
532 }
533 }
534
535 #[test]
536 fn test_zero_non_response_rate() {
537 let engagement_id = Uuid::nil();
538 let base_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
539
540 let config = ConfirmationGeneratorConfig {
541 confirmation_count: 100,
542 positive_response_rate: 0.85,
543 exception_rate: 0.10,
544 non_response_rate: 0.0,
545 type_weights: [0.40, 0.30, 0.20, 0.10],
546 };
547 let mut gen = ConfirmationGenerator::with_config(42, config);
548 let results = gen.generate_confirmations(engagement_id, base_date);
549
550 let no_responses = results
551 .iter()
552 .filter(|c| c.response_status == ConfirmationResponseStatus::NoResponse)
553 .count();
554
555 assert_eq!(
556 no_responses, 0,
557 "With non_response_rate=0.0, there should be no NoResponse statuses"
558 );
559 }
560}