Skip to main content

datasynth_generators/standards/
revenue_recognition_generator.rs

1//! Revenue Recognition Generator (ASC 606 / IFRS 15).
2//!
3//! Generates realistic customer contracts with performance obligations,
4//! variable consideration components, and revenue recognition schedules
5//! following the five-step model:
6//!
7//! 1. Identify the contract with a customer
8//! 2. Identify the performance obligations
9//! 3. Determine the transaction price
10//! 4. Allocate the transaction price to performance obligations
11//! 5. Recognize revenue when (or as) obligations are satisfied
12
13use 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
29/// Realistic company names for generated customer contracts.
30const 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
63/// Descriptions for performance obligations keyed by obligation type.
64const 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
110/// Variable consideration type descriptions.
111const 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
146/// Generator for revenue recognition contracts following ASC 606 / IFRS 15.
147///
148/// Produces realistic [`CustomerContract`] instances with performance obligations,
149/// variable consideration, and progress tracking suitable for financial data
150/// generation and audit testing scenarios.
151pub struct RevenueRecognitionGenerator {
152    /// Seeded random number generator for reproducibility.
153    rng: ChaCha8Rng,
154    /// UUID factory for contract identifiers.
155    uuid_factory: DeterministicUuidFactory,
156    /// UUID factory for performance obligation identifiers (sub-discriminator 1).
157    obligation_uuid_factory: DeterministicUuidFactory,
158}
159
160impl RevenueRecognitionGenerator {
161    /// Create a new generator with default configuration.
162    ///
163    /// # Arguments
164    ///
165    /// * `seed` - Seed for deterministic generation.
166    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    /// Create a new generator with a custom configuration seed.
179    ///
180    /// This constructor exists for API symmetry with other generators;
181    /// the actual per-generation configuration is passed to [`Self::generate`].
182    ///
183    /// # Arguments
184    ///
185    /// * `seed` - Seed for deterministic generation.
186    /// * `_config` - Revenue recognition configuration (used at generate time).
187    pub fn with_config(seed: u64, _config: &RevenueRecognitionConfig) -> Self {
188        Self::new(seed)
189    }
190
191    /// Generate a set of customer contracts for the given period.
192    ///
193    /// Produces `config.contract_count` contracts, each containing one or more
194    /// performance obligations with allocated transaction prices and progress
195    /// tracking appropriate for the specified accounting framework.
196    ///
197    /// # Arguments
198    ///
199    /// * `company_code` - The company code to associate contracts with.
200    /// * `customer_ids` - Pool of customer identifiers to draw from.
201    /// * `period_start` - Start of the generation period.
202    /// * `period_end` - End of the generation period.
203    /// * `currency` - ISO currency code (e.g., "USD").
204    /// * `config` - Revenue recognition configuration parameters.
205    /// * `framework` - Accounting framework (US GAAP, IFRS, or Dual).
206    ///
207    /// # Returns
208    ///
209    /// A vector of [`CustomerContract`] instances with fully allocated
210    /// performance obligations.
211    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    /// Generate a single customer contract with obligations and variable consideration.
248    #[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        // Pick a random customer
261        let customer_idx = self.rng.random_range(0..customer_ids.len());
262        let customer_id = &customer_ids[customer_idx];
263
264        // Pick a random customer name from the pool
265        let name_idx = self.rng.random_range(0..CUSTOMER_NAMES.len());
266        let customer_name = CUSTOMER_NAMES[name_idx];
267
268        // Random inception date within the period
269        let offset_days = self.rng.random_range(0..period_days);
270        let inception_date = period_start + chrono::Duration::days(offset_days);
271
272        // Transaction price: log-normal in $5K-$5M range
273        let transaction_price = self.generate_transaction_price();
274
275        // Create the contract with a deterministic UUID
276        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        // Generate performance obligations
289        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        // Allocate transaction price proportionally
303        self.allocate_transaction_price(&mut contract);
304
305        // Update progress for each obligation
306        self.update_obligation_progress(&mut contract, inception_date, period_end);
307
308        // Optionally add variable consideration
309        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        // Set contract status
318        contract.status = self.pick_contract_status();
319
320        // Set end date for completed/terminated contracts
321        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    /// Generate a log-normal transaction price clamped to [$5,000, $5,000,000].
333    fn generate_transaction_price(&mut self) -> Decimal {
334        // Log-normal with mu=10.0, sigma=1.5 gives a wide range centered
335        // around ~$22K with a heavy right tail.
336        let ln_dist = LogNormal::new(10.0, 1.5).unwrap_or_else(|_| {
337            // Fallback to a safe default - this should never fail with valid params
338            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        // Round to 2 decimal places
344        let price = Decimal::from_f64_retain(clamped).unwrap_or(Decimal::from(50_000));
345        price.round_dp(2)
346    }
347
348    /// Sample the number of performance obligations using a Poisson-like distribution
349    /// centered on the configured average, clamped to [1, 4].
350    fn sample_obligation_count(&mut self, avg: f64) -> u32 {
351        // Simple approach: generate from a shifted geometric-like distribution
352        // by using the average as a bias factor.
353        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    /// Generate performance obligations for a contract.
367    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        // Generate standalone selling prices that sum to roughly the total
379        // (they won't match exactly -- allocation step handles that)
380        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            // Set expected satisfaction date
405            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    /// Generate standalone selling prices that distribute around the total price.
417    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        // Generate random weights and normalize
426        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        // Normalize weights
432        for w in &mut weights {
433            *w /= weight_sum;
434        }
435
436        // Apply small markup (5-25%) to each SSP to simulate market pricing
437        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        // Ensure no zero prices
449        for price in &mut prices {
450            if *price <= Decimal::ZERO {
451                *price = Decimal::from(1_000);
452            }
453        }
454
455        prices
456    }
457
458    /// Allocate the transaction price to obligations proportionally based on SSP.
459    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            // Fallback: equal allocation
468            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                // Last obligation gets the remainder to ensure exact allocation
487                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            // Initialize deferred revenue to the full allocated price
494            po.deferred_revenue = po.allocated_price;
495        }
496    }
497
498    /// Update progress for each obligation based on how far into the period we are.
499    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                    // Progress proportional to time elapsed, with some randomness
511                    let elapsed = (period_end - inception_date).num_days().max(0) as f64;
512                    let base_progress = (elapsed / total_days) * 100.0;
513                    // Add noise: +/- 15%
514                    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                    // 70% chance the obligation is already satisfied
522                    if self.rng.random_bool(0.70) {
523                        // Satisfaction date is some time between inception and period end
524                        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                    // Otherwise remains unsatisfied (0% progress)
530                }
531            }
532        }
533    }
534
535    /// Generate a variable consideration component for a contract.
536    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        // Estimated amount is 5-20% of the transaction price
545        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        // Apply constraint (80-95% of estimated amount is "highly probable")
555        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    /// Pick a random contract status with the specified distribution:
564    /// 60% Active, 15% Complete, 10% Pending, 10% Modified, 5% Terminated.
565    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    /// Pick a random obligation type with realistic weighting.
581    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    /// Pick a description string appropriate for the obligation type.
599    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)]
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            // Every contract should have at least one obligation
652            assert!(
653                !contract.performance_obligations.is_empty(),
654                "Contract {} has no obligations",
655                contract.contract_id
656            );
657
658            // Every obligation should have a positive allocated price
659            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            // Transaction price should be within expected range
669            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            // Currency and company code should match input
681            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, // disable VC for cleaner test
745            ..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}