1use chrono::NaiveDate;
14use rand::prelude::*;
15use rand_chacha::ChaCha8Rng;
16use rand_distr::LogNormal;
17use rust_decimal::prelude::*;
18use rust_decimal::Decimal;
19
20use datasynth_config::schema::RevenueRecognitionConfig;
21use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
22use datasynth_standards::accounting::revenue::{
23 ContractStatus, CustomerContract, ObligationType, PerformanceObligation, SatisfactionPattern,
24 VariableConsideration, VariableConsiderationType,
25};
26use datasynth_standards::framework::AccountingFramework;
27
28const CUSTOMER_NAMES: &[&str] = &[
30 "Acme Corp",
31 "TechVision Inc",
32 "GlobalTrade Solutions",
33 "Pinnacle Systems",
34 "BlueHorizon Technologies",
35 "NovaStar Industries",
36 "CrestPoint Partners",
37 "Meridian Analytics",
38 "Apex Digital",
39 "Ironclad Manufacturing",
40 "Skyline Logistics",
41 "Vantage Financial Group",
42 "Quantum Dynamics",
43 "Silverline Media",
44 "ClearPath Software",
45 "Frontier Biotech",
46 "Harborview Enterprises",
47 "Summit Healthcare",
48 "CrossBridge Consulting",
49 "EverGreen Energy",
50 "Nexus Data Systems",
51 "PrimeWave Communications",
52 "RedStone Capital",
53 "TrueNorth Advisors",
54 "Atlas Robotics",
55 "BrightEdge Networks",
56 "CoreVault Security",
57 "Dragonfly Aerospace",
58 "Elevation Partners",
59 "ForgePoint Materials",
60];
61
62const GOOD_DESCRIPTIONS: &[&str] = &[
64 "Hardware equipment delivery",
65 "Manufactured goods shipment",
66 "Raw materials supply",
67 "Finished product delivery",
68 "Spare parts package",
69 "Custom fabricated components",
70];
71
72const SERVICE_DESCRIPTIONS: &[&str] = &[
73 "Professional consulting services",
74 "Implementation services",
75 "Training and onboarding program",
76 "Managed services agreement",
77 "Technical support package",
78 "System integration services",
79];
80
81const LICENSE_DESCRIPTIONS: &[&str] = &[
82 "Enterprise software license",
83 "Platform subscription license",
84 "Intellectual property license",
85 "Technology license agreement",
86 "Data analytics license",
87 "API access license",
88];
89
90const SERIES_DESCRIPTIONS: &[&str] = &[
91 "Monthly data processing services",
92 "Recurring maintenance services",
93 "Continuous monitoring services",
94 "Periodic compliance reviews",
95];
96
97const WARRANTY_DESCRIPTIONS: &[&str] = &[
98 "Extended warranty coverage",
99 "Premium support warranty",
100 "Enhanced service-level warranty",
101];
102
103const MATERIAL_RIGHT_DESCRIPTIONS: &[&str] = &[
104 "Customer loyalty program credits",
105 "Renewal discount option",
106 "Volume purchase option",
107];
108
109const VC_DESCRIPTIONS: &[(&str, VariableConsiderationType)] = &[
111 (
112 "Volume discount based on annual purchases",
113 VariableConsiderationType::Discount,
114 ),
115 (
116 "Performance rebate for meeting targets",
117 VariableConsiderationType::Rebate,
118 ),
119 (
120 "Right of return within 90-day window",
121 VariableConsiderationType::RightOfReturn,
122 ),
123 (
124 "Milestone completion bonus",
125 VariableConsiderationType::IncentiveBonus,
126 ),
127 (
128 "Late delivery penalty clause",
129 VariableConsiderationType::Penalty,
130 ),
131 (
132 "Early payment price concession",
133 VariableConsiderationType::PriceConcession,
134 ),
135 (
136 "Sales-based royalty arrangement",
137 VariableConsiderationType::Royalty,
138 ),
139 (
140 "Contingent payment on regulatory approval",
141 VariableConsiderationType::ContingentPayment,
142 ),
143];
144
145pub struct RevenueRecognitionGenerator {
151 rng: ChaCha8Rng,
153 uuid_factory: DeterministicUuidFactory,
155 obligation_uuid_factory: DeterministicUuidFactory,
157}
158
159impl RevenueRecognitionGenerator {
160 pub fn new(seed: u64) -> Self {
166 Self {
167 rng: ChaCha8Rng::seed_from_u64(seed),
168 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::RevenueRecognition),
169 obligation_uuid_factory: DeterministicUuidFactory::with_sub_discriminator(
170 seed,
171 GeneratorType::RevenueRecognition,
172 1,
173 ),
174 }
175 }
176
177 pub fn with_config(seed: u64, _config: &RevenueRecognitionConfig) -> Self {
187 Self::new(seed)
188 }
189
190 pub fn generate(
211 &mut self,
212 company_code: &str,
213 customer_ids: &[String],
214 period_start: NaiveDate,
215 period_end: NaiveDate,
216 currency: &str,
217 config: &RevenueRecognitionConfig,
218 framework: AccountingFramework,
219 ) -> Vec<CustomerContract> {
220 if customer_ids.is_empty() {
221 return Vec::new();
222 }
223
224 let count = config.contract_count;
225 let period_days = (period_end - period_start).num_days().max(1);
226
227 let mut contracts = Vec::with_capacity(count);
228
229 for _ in 0..count {
230 let contract = self.generate_single_contract(
231 company_code,
232 customer_ids,
233 period_start,
234 period_days,
235 period_end,
236 currency,
237 config,
238 framework,
239 );
240 contracts.push(contract);
241 }
242
243 contracts
244 }
245
246 #[allow(clippy::too_many_arguments)]
248 fn generate_single_contract(
249 &mut self,
250 company_code: &str,
251 customer_ids: &[String],
252 period_start: NaiveDate,
253 period_days: i64,
254 period_end: NaiveDate,
255 currency: &str,
256 config: &RevenueRecognitionConfig,
257 framework: AccountingFramework,
258 ) -> CustomerContract {
259 let customer_idx = self.rng.gen_range(0..customer_ids.len());
261 let customer_id = &customer_ids[customer_idx];
262
263 let name_idx = self.rng.gen_range(0..CUSTOMER_NAMES.len());
265 let customer_name = CUSTOMER_NAMES[name_idx];
266
267 let offset_days = self.rng.gen_range(0..period_days);
269 let inception_date = period_start + chrono::Duration::days(offset_days);
270
271 let transaction_price = self.generate_transaction_price();
273
274 let contract_id = self.uuid_factory.next();
276 let mut contract = CustomerContract::new(
277 customer_id.as_str(),
278 customer_name,
279 company_code,
280 inception_date,
281 transaction_price,
282 currency,
283 framework,
284 );
285 contract.contract_id = contract_id;
286
287 let num_obligations = self.sample_obligation_count(config.avg_obligations_per_contract);
289 let obligations = self.generate_obligations(
290 contract.contract_id,
291 num_obligations,
292 transaction_price,
293 config.over_time_recognition_rate,
294 inception_date,
295 period_end,
296 );
297 for obligation in obligations {
298 contract.add_performance_obligation(obligation);
299 }
300
301 self.allocate_transaction_price(&mut contract);
303
304 self.update_obligation_progress(&mut contract, inception_date, period_end);
306
307 if self
309 .rng
310 .gen_bool(config.variable_consideration_rate.clamp(0.0, 1.0))
311 {
312 let vc = self.generate_variable_consideration(contract.contract_id, transaction_price);
313 contract.add_variable_consideration(vc);
314 }
315
316 contract.status = self.pick_contract_status();
318
319 match contract.status {
321 ContractStatus::Complete | ContractStatus::Terminated => {
322 let days_after = self.rng.gen_range(30..365);
323 contract.end_date = Some(inception_date + chrono::Duration::days(days_after));
324 }
325 _ => {}
326 }
327
328 contract
329 }
330
331 fn generate_transaction_price(&mut self) -> Decimal {
333 let ln_dist = LogNormal::new(10.0, 1.5).unwrap_or_else(|_| {
336 LogNormal::new(10.0, 1.0).expect("fallback log-normal must succeed")
338 });
339 let raw: f64 = self.rng.sample(ln_dist);
340 let clamped = raw.clamp(5_000.0, 5_000_000.0);
341
342 let price = Decimal::from_f64_retain(clamped).unwrap_or(Decimal::from(50_000));
344 price.round_dp(2)
345 }
346
347 fn sample_obligation_count(&mut self, avg: f64) -> u32 {
350 let base: f64 = self.rng.gen();
353 let count = if base < 0.3 {
354 1
355 } else if base < 0.3 + 0.4 * (avg / 2.0).min(1.0) {
356 2
357 } else if base < 0.85 {
358 3
359 } else {
360 4
361 };
362 count.clamp(1, 4)
363 }
364
365 fn generate_obligations(
367 &mut self,
368 contract_id: uuid::Uuid,
369 count: u32,
370 total_price: Decimal,
371 over_time_rate: f64,
372 inception_date: NaiveDate,
373 period_end: NaiveDate,
374 ) -> Vec<PerformanceObligation> {
375 let mut obligations = Vec::with_capacity(count as usize);
376
377 let ssp_values = self.generate_standalone_prices(count, total_price);
380
381 for seq in 0..count {
382 let ob_type = self.pick_obligation_type();
383 let satisfaction = if self.rng.gen_bool(over_time_rate.clamp(0.0, 1.0)) {
384 SatisfactionPattern::OverTime
385 } else {
386 SatisfactionPattern::PointInTime
387 };
388
389 let description = self.pick_obligation_description(ob_type);
390 let ssp = ssp_values[seq as usize];
391
392 let ob_id = self.obligation_uuid_factory.next();
393 let mut obligation = PerformanceObligation::new(
394 contract_id,
395 seq + 1,
396 description,
397 ob_type,
398 satisfaction,
399 ssp,
400 );
401 obligation.obligation_id = ob_id;
402
403 let days_to_satisfy = self.rng.gen_range(30..365);
405 let expected_date = inception_date + chrono::Duration::days(days_to_satisfy);
406 obligation.expected_satisfaction_date =
407 Some(expected_date.min(period_end + chrono::Duration::days(365)));
408
409 obligations.push(obligation);
410 }
411
412 obligations
413 }
414
415 fn generate_standalone_prices(&mut self, count: u32, total_price: Decimal) -> Vec<Decimal> {
417 if count == 0 {
418 return Vec::new();
419 }
420 if count == 1 {
421 return vec![total_price];
422 }
423
424 let mut weights: Vec<f64> = (0..count)
426 .map(|_| self.rng.gen_range(0.2_f64..1.0))
427 .collect();
428 let weight_sum: f64 = weights.iter().sum();
429
430 for w in &mut weights {
432 *w /= weight_sum;
433 }
434
435 let mut prices: Vec<Decimal> = weights
437 .iter()
438 .map(|w| {
439 let markup = 1.0 + self.rng.gen_range(0.05..0.25);
440 let ssp_f64 = w * total_price.to_f64().unwrap_or(50_000.0) * markup;
441 Decimal::from_f64_retain(ssp_f64)
442 .unwrap_or(Decimal::ONE)
443 .round_dp(2)
444 })
445 .collect();
446
447 for price in &mut prices {
449 if *price <= Decimal::ZERO {
450 *price = Decimal::from(1_000);
451 }
452 }
453
454 prices
455 }
456
457 fn allocate_transaction_price(&mut self, contract: &mut CustomerContract) {
459 let total_ssp: Decimal = contract
460 .performance_obligations
461 .iter()
462 .map(|po| po.standalone_selling_price)
463 .sum();
464
465 if total_ssp <= Decimal::ZERO {
466 let per_ob = if contract.performance_obligations.is_empty() {
468 Decimal::ZERO
469 } else {
470 let count_dec = Decimal::from(contract.performance_obligations.len() as u32);
471 (contract.transaction_price / count_dec).round_dp(2)
472 };
473 for po in &mut contract.performance_obligations {
474 po.allocated_price = per_ob;
475 }
476 return;
477 }
478
479 let tx_price = contract.transaction_price;
480 let mut allocated_total = Decimal::ZERO;
481
482 let ob_count = contract.performance_obligations.len();
483 for (i, po) in contract.performance_obligations.iter_mut().enumerate() {
484 if i == ob_count - 1 {
485 po.allocated_price = (tx_price - allocated_total).max(Decimal::ZERO);
487 } else {
488 let ratio = po.standalone_selling_price / total_ssp;
489 po.allocated_price = (tx_price * ratio).round_dp(2);
490 allocated_total += po.allocated_price;
491 }
492 po.deferred_revenue = po.allocated_price;
494 }
495 }
496
497 fn update_obligation_progress(
499 &mut self,
500 contract: &mut CustomerContract,
501 inception_date: NaiveDate,
502 period_end: NaiveDate,
503 ) {
504 let total_days = (period_end - inception_date).num_days().max(1) as f64;
505
506 for po in &mut contract.performance_obligations {
507 match po.satisfaction_pattern {
508 SatisfactionPattern::OverTime => {
509 let elapsed = (period_end - inception_date).num_days().max(0) as f64;
511 let base_progress = (elapsed / total_days) * 100.0;
512 let noise = self.rng.gen_range(-15.0_f64..15.0);
514 let progress = (base_progress + noise).clamp(5.0, 95.0);
515 let progress_dec =
516 Decimal::from_f64_retain(progress).unwrap_or(Decimal::from(50));
517 po.update_progress(progress_dec, period_end);
518 }
519 SatisfactionPattern::PointInTime => {
520 if self.rng.gen_bool(0.70) {
522 let max_offset = (period_end - inception_date).num_days().max(1);
524 let sat_offset = self.rng.gen_range(0..max_offset);
525 let sat_date = inception_date + chrono::Duration::days(sat_offset);
526 po.update_progress(Decimal::from(100), sat_date);
527 }
528 }
530 }
531 }
532 }
533
534 fn generate_variable_consideration(
536 &mut self,
537 contract_id: uuid::Uuid,
538 transaction_price: Decimal,
539 ) -> VariableConsideration {
540 let idx = self.rng.gen_range(0..VC_DESCRIPTIONS.len());
541 let (description, vc_type) = VC_DESCRIPTIONS[idx];
542
543 let pct = self.rng.gen_range(0.05..0.20);
545 let estimated_f64 = transaction_price.to_f64().unwrap_or(50_000.0) * pct;
546 let estimated_amount = Decimal::from_f64_retain(estimated_f64)
547 .unwrap_or(Decimal::from(5_000))
548 .round_dp(2);
549
550 let mut vc =
551 VariableConsideration::new(contract_id, vc_type, estimated_amount, description);
552
553 let constraint_pct = self.rng.gen_range(0.80..0.95);
555 let constraint_dec = Decimal::from_f64_retain(constraint_pct)
556 .unwrap_or(Decimal::from_str("0.85").unwrap_or(Decimal::ONE));
557 vc.apply_constraint(constraint_dec);
558
559 vc
560 }
561
562 fn pick_contract_status(&mut self) -> ContractStatus {
565 let roll: f64 = self.rng.gen();
566 if roll < 0.60 {
567 ContractStatus::Active
568 } else if roll < 0.75 {
569 ContractStatus::Complete
570 } else if roll < 0.85 {
571 ContractStatus::Pending
572 } else if roll < 0.95 {
573 ContractStatus::Modified
574 } else {
575 ContractStatus::Terminated
576 }
577 }
578
579 fn pick_obligation_type(&mut self) -> ObligationType {
581 let roll: f64 = self.rng.gen();
582 if roll < 0.25 {
583 ObligationType::Good
584 } else if roll < 0.50 {
585 ObligationType::Service
586 } else if roll < 0.70 {
587 ObligationType::License
588 } else if roll < 0.82 {
589 ObligationType::Series
590 } else if roll < 0.92 {
591 ObligationType::ServiceTypeWarranty
592 } else {
593 ObligationType::MaterialRight
594 }
595 }
596
597 fn pick_obligation_description(&mut self, ob_type: ObligationType) -> &'static str {
599 let pool = match ob_type {
600 ObligationType::Good => GOOD_DESCRIPTIONS,
601 ObligationType::Service => SERVICE_DESCRIPTIONS,
602 ObligationType::License => LICENSE_DESCRIPTIONS,
603 ObligationType::Series => SERIES_DESCRIPTIONS,
604 ObligationType::ServiceTypeWarranty => WARRANTY_DESCRIPTIONS,
605 ObligationType::MaterialRight => MATERIAL_RIGHT_DESCRIPTIONS,
606 };
607 let idx = self.rng.gen_range(0..pool.len());
608 pool[idx]
609 }
610}
611
612#[cfg(test)]
613#[allow(clippy::unwrap_used)]
614mod tests {
615 use super::*;
616
617 fn default_config() -> RevenueRecognitionConfig {
618 RevenueRecognitionConfig {
619 enabled: true,
620 generate_contracts: true,
621 avg_obligations_per_contract: 2.0,
622 variable_consideration_rate: 0.15,
623 over_time_recognition_rate: 0.30,
624 contract_count: 10,
625 }
626 }
627
628 fn sample_customer_ids() -> Vec<String> {
629 (1..=20).map(|i| format!("CUST{:04}", i)).collect()
630 }
631
632 #[test]
633 fn test_basic_generation() {
634 let mut gen = RevenueRecognitionGenerator::new(42);
635 let config = default_config();
636 let customers = sample_customer_ids();
637
638 let contracts = gen.generate(
639 "1000",
640 &customers,
641 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
642 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
643 "USD",
644 &config,
645 AccountingFramework::UsGaap,
646 );
647
648 assert_eq!(contracts.len(), 10);
649
650 for contract in &contracts {
651 assert!(
653 !contract.performance_obligations.is_empty(),
654 "Contract {} has no obligations",
655 contract.contract_id
656 );
657
658 for po in &contract.performance_obligations {
660 assert!(
661 po.allocated_price > Decimal::ZERO,
662 "Obligation {} has non-positive allocated price: {}",
663 po.obligation_id,
664 po.allocated_price
665 );
666 }
667
668 assert!(
670 contract.transaction_price >= Decimal::from(5_000),
671 "Transaction price too low: {}",
672 contract.transaction_price
673 );
674 assert!(
675 contract.transaction_price <= Decimal::from(5_000_000),
676 "Transaction price too high: {}",
677 contract.transaction_price
678 );
679
680 assert_eq!(contract.currency, "USD");
682 assert_eq!(contract.company_code, "1000");
683 }
684 }
685
686 #[test]
687 fn test_deterministic_output() {
688 let config = default_config();
689 let customers = sample_customer_ids();
690 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
691 let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
692
693 let mut gen1 = RevenueRecognitionGenerator::new(12345);
694 let contracts1 = gen1.generate(
695 "1000",
696 &customers,
697 start,
698 end,
699 "USD",
700 &config,
701 AccountingFramework::UsGaap,
702 );
703
704 let mut gen2 = RevenueRecognitionGenerator::new(12345);
705 let contracts2 = gen2.generate(
706 "1000",
707 &customers,
708 start,
709 end,
710 "USD",
711 &config,
712 AccountingFramework::UsGaap,
713 );
714
715 assert_eq!(contracts1.len(), contracts2.len());
716
717 for (c1, c2) in contracts1.iter().zip(contracts2.iter()) {
718 assert_eq!(c1.contract_id, c2.contract_id);
719 assert_eq!(c1.customer_id, c2.customer_id);
720 assert_eq!(c1.transaction_price, c2.transaction_price);
721 assert_eq!(c1.inception_date, c2.inception_date);
722 assert_eq!(
723 c1.performance_obligations.len(),
724 c2.performance_obligations.len()
725 );
726
727 for (po1, po2) in c1
728 .performance_obligations
729 .iter()
730 .zip(c2.performance_obligations.iter())
731 {
732 assert_eq!(po1.obligation_id, po2.obligation_id);
733 assert_eq!(po1.allocated_price, po2.allocated_price);
734 assert_eq!(po1.standalone_selling_price, po2.standalone_selling_price);
735 }
736 }
737 }
738
739 #[test]
740 fn test_obligation_allocation_sums_to_transaction_price() {
741 let mut gen = RevenueRecognitionGenerator::new(99);
742 let config = RevenueRecognitionConfig {
743 contract_count: 50,
744 variable_consideration_rate: 0.0, ..default_config()
746 };
747 let customers = sample_customer_ids();
748 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
749 let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
750
751 let contracts = gen.generate(
752 "2000",
753 &customers,
754 start,
755 end,
756 "EUR",
757 &config,
758 AccountingFramework::Ifrs,
759 );
760
761 for contract in &contracts {
762 let total_allocated: Decimal = contract
763 .performance_obligations
764 .iter()
765 .map(|po| po.allocated_price)
766 .sum();
767
768 assert_eq!(
769 total_allocated, contract.transaction_price,
770 "Allocation mismatch for contract {}: allocated={} vs transaction_price={}",
771 contract.contract_id, total_allocated, contract.transaction_price
772 );
773 }
774 }
775
776 #[test]
777 fn test_empty_customer_ids_returns_empty() {
778 let mut gen = RevenueRecognitionGenerator::new(1);
779 let config = default_config();
780 let empty: Vec<String> = vec![];
781
782 let contracts = gen.generate(
783 "1000",
784 &empty,
785 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
786 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
787 "USD",
788 &config,
789 AccountingFramework::UsGaap,
790 );
791
792 assert!(contracts.is_empty());
793 }
794}