Skip to main content

datasynth_generators/period_close/
segment_generator.rs

1//! IFRS 8 / ASC 280 Operating Segment Reporting generator.
2//!
3//! Produces:
4//! - A set of [`OperatingSegment`] records that partition the consolidated
5//!   financials into reportable segments (geographic or product-line).
6//! - A [`SegmentReconciliation`] that proves segment totals tie back to
7//!   the consolidated income-statement and balance-sheet totals.
8//!
9//! ## Segment derivation logic
10//!
11//! | Config | Segment basis |
12//! |--------|---------------|
13//! | Multi-entity (≥2 companies) | One `Geographic` segment per company |
14//! | Single-entity | 2–3 `ProductLine` segments from CoA revenue sub-ranges |
15//!
16//! ## Reconciliation identity
17//!
18//! ```text
19//! consolidated_revenue  = Σ revenue_external
20//!                       = segment_revenue_total + intersegment_eliminations
21//! consolidated_profit   = segment_profit_total  + corporate_overhead
22//! consolidated_assets   = segment_assets_total  + unallocated_assets
23//! ```
24
25use datasynth_core::models::{OperatingSegment, SegmentReconciliation, SegmentType};
26use datasynth_core::utils::seeded_rng;
27use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
28use rand::prelude::*;
29use rand_chacha::ChaCha8Rng;
30use rust_decimal::Decimal;
31use tracing::debug;
32
33/// Generates IFRS 8 / ASC 280 segment reporting data.
34pub struct SegmentGenerator {
35    rng: ChaCha8Rng,
36    uuid_factory: DeterministicUuidFactory,
37}
38
39/// Lightweight description of one entity / business unit used to seed segment names.
40#[derive(Debug, Clone)]
41pub struct SegmentSeed {
42    /// Company or business-unit code (e.g. "C001")
43    pub code: String,
44    /// Human-readable name (e.g. "North America" or "Software Products")
45    pub name: String,
46    /// Currency used by this entity (informational only)
47    pub currency: String,
48}
49
50impl SegmentGenerator {
51    /// Create a new segment generator with the given seed.
52    pub fn new(seed: u64) -> Self {
53        Self {
54            rng: seeded_rng(seed, 0),
55            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::SegmentReport),
56        }
57    }
58
59    /// Generate operating segments and a reconciliation for one fiscal period.
60    ///
61    /// # Arguments
62    /// * `company_code` – group / parent company code used in output records
63    /// * `period` – fiscal period label (e.g. "2024-03")
64    /// * `consolidated_revenue` – total external revenue from the consolidated IS
65    /// * `consolidated_profit` – consolidated operating profit
66    /// * `consolidated_assets` – consolidated total assets
67    /// * `entity_seeds` – one entry per legal entity / product line to derive segments from
68    ///
69    /// # Returns
70    /// `(segments, reconciliation)` where the reconciliation ties segment totals
71    /// back to the consolidated figures passed in.
72    pub fn generate(
73        &mut self,
74        company_code: &str,
75        period: &str,
76        consolidated_revenue: Decimal,
77        consolidated_profit: Decimal,
78        consolidated_assets: Decimal,
79        entity_seeds: &[SegmentSeed],
80    ) -> (Vec<OperatingSegment>, SegmentReconciliation) {
81        debug!(
82            company_code,
83            period,
84            consolidated_revenue = consolidated_revenue.to_string(),
85            consolidated_profit = consolidated_profit.to_string(),
86            consolidated_assets = consolidated_assets.to_string(),
87            n_seeds = entity_seeds.len(),
88            "Generating segment reports"
89        );
90
91        // Determine segment type and names
92        let (segment_type, segment_names) = if entity_seeds.len() >= 2 {
93            // Multi-entity: geographic segments = one per legal entity
94            let names: Vec<String> = entity_seeds.iter().map(|s| s.name.clone()).collect();
95            (SegmentType::Geographic, names)
96        } else {
97            // Single-entity: create 2-3 product line segments
98            (SegmentType::ProductLine, self.default_product_lines())
99        };
100
101        let n = segment_names.len().clamp(2, 8);
102
103        // Generate proportional splits for revenue, profit, assets
104        let rev_splits = self.random_proportions(n);
105        let profit_multipliers = self.profit_multipliers(n);
106        let asset_splits = self.random_proportions(n);
107
108        // Build segments
109        let mut segments: Vec<OperatingSegment> = Vec::with_capacity(n);
110
111        // Intersegment revenue: only meaningful for geographic / multi-entity
112        // Use 3–8 % of gross revenue as intersegment transactions
113        let intersegment_rate = if segment_type == SegmentType::Geographic && n >= 2 {
114            let rate_bps = self.rng.random_range(300u32..=800);
115            Decimal::from(rate_bps) / Decimal::from(10_000u32)
116        } else {
117            Decimal::ZERO
118        };
119
120        let total_intersegment = consolidated_revenue.abs() * intersegment_rate;
121
122        // We want: Σ revenue_external = consolidated_revenue
123        // Therefore allocate consolidated_revenue proportionally as external revenue.
124        // Intersegment revenue is additional on top (it cancels in consolidation).
125        let mut remaining_rev = consolidated_revenue;
126        let mut remaining_profit = consolidated_profit;
127        let mut remaining_assets = consolidated_assets;
128
129        for (i, name) in segment_names.iter().take(n).enumerate() {
130            let is_last = i == n - 1;
131
132            let ext_rev = if is_last {
133                remaining_rev
134            } else {
135                let r = consolidated_revenue * rev_splits[i];
136                remaining_rev -= r;
137                r
138            };
139
140            // Intersegment: distribute evenly across all segments (they net to zero)
141            let interseg_rev = if intersegment_rate > Decimal::ZERO {
142                total_intersegment * rev_splits[i]
143            } else {
144                Decimal::ZERO
145            };
146
147            // Operating profit: apply per-segment margin multiplier
148            let seg_profit = if is_last {
149                remaining_profit
150            } else {
151                let base_margin = if consolidated_revenue != Decimal::ZERO {
152                    consolidated_profit / consolidated_revenue
153                } else {
154                    Decimal::ZERO
155                };
156                let adjusted_margin = base_margin * profit_multipliers[i];
157                let p = ext_rev * adjusted_margin;
158                remaining_profit -= p;
159                p
160            };
161
162            let seg_assets = if is_last {
163                remaining_assets
164            } else {
165                let a = consolidated_assets * asset_splits[i];
166                remaining_assets -= a;
167                a
168            };
169
170            // Liabilities: assume ~40-60 % of assets ratio with some noise
171            let liab_ratio =
172                Decimal::from(self.rng.random_range(35u32..=55)) / Decimal::from(100u32);
173            let seg_liabilities = (seg_assets * liab_ratio).max(Decimal::ZERO);
174
175            // CapEx: ~3-8 % of segment assets
176            let capex_rate =
177                Decimal::from(self.rng.random_range(30u32..=80)) / Decimal::from(1_000u32);
178            let capex = (seg_assets * capex_rate).max(Decimal::ZERO);
179
180            // D&A: ~50-80 % of CapEx (known simplification).
181            // TODO: A production implementation would derive D&A from the fixed-asset
182            // subledger depreciation run (DepreciationRun) rather than proxying it as a
183            // fraction of CapEx. The 50–80 % range is a reasonable heuristic for
184            // synthetic audit-simulation data where segment-level depreciation schedules
185            // are not individually tracked.
186            let da_ratio = Decimal::from(self.rng.random_range(50u32..=80)) / Decimal::from(100u32);
187            let da = capex * da_ratio;
188
189            segments.push(OperatingSegment {
190                segment_id: self.uuid_factory.next().to_string(),
191                name: name.clone(),
192                segment_type,
193                revenue_external: ext_rev,
194                revenue_intersegment: interseg_rev,
195                operating_profit: seg_profit,
196                total_assets: seg_assets,
197                total_liabilities: seg_liabilities,
198                capital_expenditure: capex,
199                depreciation_amortization: da,
200                period: period.to_string(),
201                company_code: company_code.to_string(),
202            });
203        }
204
205        // Corporate overhead: 2–5 % of consolidated revenue (negative)
206        let overhead_rate = Decimal::from(self.rng.random_range(2u32..=5)) / Decimal::from(100u32);
207        let corporate_overhead = -(consolidated_revenue.abs() * overhead_rate);
208
209        // Unallocated assets: goodwill, deferred tax, etc — 5-12 % of total
210        let unalloc_rate = Decimal::from(self.rng.random_range(5u32..=12)) / Decimal::from(100u32);
211        let unallocated_assets = consolidated_assets.abs() * unalloc_rate;
212
213        // Segment totals
214        let segment_revenue_total: Decimal = segments
215            .iter()
216            .map(|s| s.revenue_external + s.revenue_intersegment)
217            .sum();
218
219        // Intersegment eliminations are derived to enforce the exact identity:
220        //   segment_revenue_total + intersegment_eliminations = consolidated_revenue
221        // This avoids any decimal precision residual from proportion arithmetic.
222        let intersegment_eliminations = consolidated_revenue - segment_revenue_total;
223
224        let segment_profit_total: Decimal = segments.iter().map(|s| s.operating_profit).sum();
225        let segment_assets_total: Decimal = segments.iter().map(|s| s.total_assets).sum();
226
227        let reconciliation = SegmentReconciliation {
228            period: period.to_string(),
229            company_code: company_code.to_string(),
230            segment_revenue_total,
231            intersegment_eliminations,
232            consolidated_revenue,
233            segment_profit_total,
234            corporate_overhead,
235            consolidated_profit: segment_profit_total + corporate_overhead,
236            segment_assets_total,
237            unallocated_assets,
238            consolidated_assets: segment_assets_total + unallocated_assets,
239        };
240
241        (segments, reconciliation)
242    }
243
244    // -----------------------------------------------------------------------
245    // Private helpers
246    // -----------------------------------------------------------------------
247
248    /// Generate a default list of 3 product-line segment names.
249    fn default_product_lines(&mut self) -> Vec<String> {
250        let options: &[&[&str]] = &[
251            &["Products", "Services", "Licensing"],
252            &["Hardware", "Software", "Support"],
253            &["Consumer", "Commercial", "Enterprise"],
254            &["Core", "Growth", "Emerging"],
255            &["Domestic", "International", "Other"],
256        ];
257        let idx = self.rng.random_range(0..options.len());
258        options[idx].iter().map(|s| s.to_string()).collect()
259    }
260
261    /// Generate n random proportions that sum to 1.
262    fn random_proportions(&mut self, n: usize) -> Vec<Decimal> {
263        // Draw n uniform samples and normalise
264        let raw: Vec<f64> = (0..n)
265            .map(|_| self.rng.random_range(1u32..=100) as f64)
266            .collect();
267        let total: f64 = raw.iter().sum();
268        raw.iter()
269            .map(|v| Decimal::from_f64_retain(v / total).unwrap_or(Decimal::ZERO))
270            .collect()
271    }
272
273    /// Generate per-segment profit multipliers (relative margin adjustments) around 1.0.
274    fn profit_multipliers(&mut self, n: usize) -> Vec<Decimal> {
275        (0..n)
276            .map(|_| {
277                let m = self.rng.random_range(70u32..=130);
278                Decimal::from(m) / Decimal::from(100u32)
279            })
280            .collect()
281    }
282}
283
284#[cfg(test)]
285#[allow(clippy::unwrap_used)]
286mod tests {
287    use super::*;
288
289    fn make_seeds(names: &[&str]) -> Vec<SegmentSeed> {
290        names
291            .iter()
292            .enumerate()
293            .map(|(i, n)| SegmentSeed {
294                code: format!("C{:03}", i + 1),
295                name: n.to_string(),
296                currency: "USD".to_string(),
297            })
298            .collect()
299    }
300
301    #[test]
302    fn test_segment_totals_match_consolidated_revenue() {
303        let mut gen = SegmentGenerator::new(42);
304        let seeds = make_seeds(&["North America", "Europe"]);
305
306        let rev = Decimal::from(1_000_000);
307        let profit = Decimal::from(150_000);
308        let assets = Decimal::from(5_000_000);
309
310        let (segments, recon) = gen.generate("GROUP", "2024-03", rev, profit, assets, &seeds);
311
312        assert!(!segments.is_empty());
313
314        // Σ external revenues = consolidated_revenue
315        let sum_ext: Decimal = segments.iter().map(|s| s.revenue_external).sum();
316        assert_eq!(
317            sum_ext, rev,
318            "Σ external revenue should equal consolidated_revenue"
319        );
320
321        // Reconciliation identity: segment_revenue_total + eliminations = consolidated_revenue
322        let computed = recon.segment_revenue_total + recon.intersegment_eliminations;
323        assert_eq!(
324            computed, recon.consolidated_revenue,
325            "segment_revenue_total + eliminations ≠ consolidated_revenue"
326        );
327    }
328
329    #[test]
330    fn test_reconciliation_profit_math() {
331        let mut gen = SegmentGenerator::new(99);
332        let seeds = make_seeds(&["Americas", "EMEA", "APAC"]);
333
334        let (_, recon) = gen.generate(
335            "CORP",
336            "2024-06",
337            Decimal::from(2_000_000),
338            Decimal::from(300_000),
339            Decimal::from(8_000_000),
340            &seeds,
341        );
342
343        // consolidated_profit = segment_profit_total + corporate_overhead
344        assert_eq!(
345            recon.consolidated_profit,
346            recon.segment_profit_total + recon.corporate_overhead,
347            "Profit reconciliation identity failed"
348        );
349    }
350
351    #[test]
352    fn test_reconciliation_assets_math() {
353        let mut gen = SegmentGenerator::new(7);
354        let seeds = make_seeds(&["ProductA", "ProductB"]);
355
356        let (_, recon) = gen.generate(
357            "C001",
358            "2024-01",
359            Decimal::from(500_000),
360            Decimal::from(50_000),
361            Decimal::from(3_000_000),
362            &seeds,
363        );
364
365        // consolidated_assets = segment_assets_total + unallocated_assets
366        assert_eq!(
367            recon.consolidated_assets,
368            recon.segment_assets_total + recon.unallocated_assets,
369            "Asset reconciliation identity failed"
370        );
371    }
372
373    #[test]
374    fn test_each_segment_has_positive_external_revenue() {
375        let mut gen = SegmentGenerator::new(42);
376        // Use seeds with all positive consolidated numbers
377        let seeds = make_seeds(&["SegA", "SegB", "SegC"]);
378        let rev = Decimal::from(3_000_000);
379        let profit = Decimal::from(600_000);
380        let assets = Decimal::from(10_000_000);
381
382        let (segments, _) = gen.generate("GRP", "2024-12", rev, profit, assets, &seeds);
383
384        for seg in &segments {
385            assert!(
386                seg.revenue_external >= Decimal::ZERO,
387                "Segment '{}' has negative external revenue: {}",
388                seg.name,
389                seg.revenue_external
390            );
391        }
392    }
393
394    #[test]
395    fn test_single_entity_uses_product_lines() {
396        let mut gen = SegmentGenerator::new(1234);
397        let seeds = make_seeds(&["OnlyEntity"]);
398
399        let (segments, _) = gen.generate(
400            "C001",
401            "2024-03",
402            Decimal::from(1_000_000),
403            Decimal::from(100_000),
404            Decimal::from(4_000_000),
405            &seeds,
406        );
407
408        // With a single seed, product-line segments should be generated (≥ 2)
409        assert!(segments.len() >= 2, "Expected ≥ 2 product-line segments");
410        // All should be ProductLine type
411        for seg in &segments {
412            assert_eq!(seg.segment_type, SegmentType::ProductLine);
413        }
414    }
415
416    #[test]
417    fn test_multi_entity_uses_geographic_segments() {
418        let mut gen = SegmentGenerator::new(5678);
419        let seeds = make_seeds(&["US", "DE", "JP"]);
420
421        let (segments, _) = gen.generate(
422            "GROUP",
423            "2024-03",
424            Decimal::from(9_000_000),
425            Decimal::from(900_000),
426            Decimal::from(30_000_000),
427            &seeds,
428        );
429
430        assert_eq!(segments.len(), 3);
431        for seg in &segments {
432            assert_eq!(seg.segment_type, SegmentType::Geographic);
433        }
434    }
435
436    #[test]
437    fn test_deterministic() {
438        let seeds = make_seeds(&["A", "B"]);
439        let rev = Decimal::from(1_000_000);
440        let profit = Decimal::from(200_000);
441        let assets = Decimal::from(5_000_000);
442
443        let (segs1, recon1) =
444            SegmentGenerator::new(42).generate("G", "2024-01", rev, profit, assets, &seeds);
445        let (segs2, recon2) =
446            SegmentGenerator::new(42).generate("G", "2024-01", rev, profit, assets, &seeds);
447
448        assert_eq!(segs1.len(), segs2.len());
449        for (a, b) in segs1.iter().zip(segs2.iter()) {
450            assert_eq!(a.segment_id, b.segment_id);
451            assert_eq!(a.revenue_external, b.revenue_external);
452        }
453        assert_eq!(recon1.consolidated_revenue, recon2.consolidated_revenue);
454        assert_eq!(recon1.segment_profit_total, recon2.segment_profit_total);
455    }
456}