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