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)]
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            // Every contract should have at least one obligation
653            assert!(
654                !contract.performance_obligations.is_empty(),
655                "Contract {} has no obligations",
656                contract.contract_id
657            );
658
659            // Every obligation should have a positive allocated price
660            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            // Transaction price should be within expected range
670            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            // Currency and company code should match input
682            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, // disable VC for cleaner test
746            ..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}