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