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::{JournalEntry, 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    /// * `total_depreciation` – consolidated D&A from the depreciation run (e.g.
69    ///   `DepreciationRun.total_depreciation`).  When `Some`, distributed to segments
70    ///   proportionally to their share of total assets.  When `None`, D&A is approximated
71    ///   as 50–80 % of each segment's CapEx (a reasonable synthetic-data heuristic).
72    ///
73    /// # Returns
74    /// `(segments, reconciliation)` where the reconciliation ties segment totals
75    /// back to the consolidated figures passed in.
76    pub fn generate(
77        &mut self,
78        company_code: &str,
79        period: &str,
80        consolidated_revenue: Decimal,
81        consolidated_profit: Decimal,
82        consolidated_assets: Decimal,
83        entity_seeds: &[SegmentSeed],
84        total_depreciation: Option<Decimal>,
85    ) -> (Vec<OperatingSegment>, SegmentReconciliation) {
86        debug!(
87            company_code,
88            period,
89            consolidated_revenue = consolidated_revenue.to_string(),
90            consolidated_profit = consolidated_profit.to_string(),
91            consolidated_assets = consolidated_assets.to_string(),
92            n_seeds = entity_seeds.len(),
93            "Generating segment reports"
94        );
95
96        // Determine segment type and names
97        let (segment_type, segment_names) = if entity_seeds.len() >= 2 {
98            // Multi-entity: geographic segments = one per legal entity
99            let names: Vec<String> = entity_seeds.iter().map(|s| s.name.clone()).collect();
100            (SegmentType::Geographic, names)
101        } else {
102            // Single-entity: create 2-3 product line segments
103            (SegmentType::ProductLine, self.default_product_lines())
104        };
105
106        let n = segment_names.len().clamp(2, 8);
107
108        // Generate proportional splits for revenue, profit, assets
109        let rev_splits = self.random_proportions(n);
110        let profit_multipliers = self.profit_multipliers(n);
111        let asset_splits = self.random_proportions(n);
112
113        // Build segments
114        let mut segments: Vec<OperatingSegment> = Vec::with_capacity(n);
115
116        // Intersegment revenue: only meaningful for geographic / multi-entity
117        // Use 3–8 % of gross revenue as intersegment transactions
118        let intersegment_rate = if segment_type == SegmentType::Geographic && n >= 2 {
119            let rate_bps = self.rng.random_range(300u32..=800);
120            Decimal::from(rate_bps) / Decimal::from(10_000u32)
121        } else {
122            Decimal::ZERO
123        };
124
125        let total_intersegment = consolidated_revenue.abs() * intersegment_rate;
126
127        // We want: Σ revenue_external = consolidated_revenue
128        // Therefore allocate consolidated_revenue proportionally as external revenue.
129        // Intersegment revenue is additional on top (it cancels in consolidation).
130        let mut remaining_rev = consolidated_revenue;
131        let mut remaining_profit = consolidated_profit;
132        let mut remaining_assets = consolidated_assets;
133
134        for (i, name) in segment_names.iter().take(n).enumerate() {
135            let is_last = i == n - 1;
136
137            let ext_rev = if is_last {
138                remaining_rev
139            } else {
140                let r = consolidated_revenue * rev_splits[i];
141                remaining_rev -= r;
142                r
143            };
144
145            // Intersegment: distribute evenly across all segments (they net to zero)
146            let interseg_rev = if intersegment_rate > Decimal::ZERO {
147                total_intersegment * rev_splits[i]
148            } else {
149                Decimal::ZERO
150            };
151
152            // Operating profit: apply per-segment margin multiplier
153            let seg_profit = if is_last {
154                remaining_profit
155            } else {
156                let base_margin = if consolidated_revenue != Decimal::ZERO {
157                    consolidated_profit / consolidated_revenue
158                } else {
159                    Decimal::ZERO
160                };
161                let adjusted_margin = base_margin * profit_multipliers[i];
162                let p = ext_rev * adjusted_margin;
163                remaining_profit -= p;
164                p
165            };
166
167            let seg_assets = if is_last {
168                remaining_assets
169            } else {
170                let a = consolidated_assets * asset_splits[i];
171                remaining_assets -= a;
172                a
173            };
174
175            // Liabilities: assume ~40-60 % of assets ratio with some noise
176            let liab_ratio =
177                Decimal::from(self.rng.random_range(35u32..=55)) / Decimal::from(100u32);
178            let seg_liabilities = (seg_assets * liab_ratio).max(Decimal::ZERO);
179
180            // CapEx: ~3-8 % of segment assets
181            let capex_rate =
182                Decimal::from(self.rng.random_range(30u32..=80)) / Decimal::from(1_000u32);
183            let capex = (seg_assets * capex_rate).max(Decimal::ZERO);
184
185            // D&A: when `total_depreciation` is provided (from the FA subledger depreciation
186            // run), distribute it proportionally to each segment's share of total assets.
187            // Fall back to the 50–80 % of CapEx heuristic when no actual depreciation data
188            // is available (e.g. when the FA subledger is not generated).
189            let da = if let Some(total_depr) = total_depreciation {
190                let asset_share = if consolidated_assets != Decimal::ZERO {
191                    seg_assets / consolidated_assets
192                } else {
193                    asset_splits[i]
194                };
195                (total_depr * asset_share).max(Decimal::ZERO)
196            } else {
197                let da_ratio =
198                    Decimal::from(self.rng.random_range(50u32..=80)) / Decimal::from(100u32);
199                capex * da_ratio
200            };
201
202            segments.push(OperatingSegment {
203                segment_id: self.uuid_factory.next().to_string(),
204                name: name.clone(),
205                segment_type,
206                revenue_external: ext_rev,
207                revenue_intersegment: interseg_rev,
208                operating_profit: seg_profit,
209                total_assets: seg_assets,
210                total_liabilities: seg_liabilities,
211                capital_expenditure: capex,
212                depreciation_amortization: da,
213                period: period.to_string(),
214                company_code: company_code.to_string(),
215            });
216        }
217
218        // Unallocated assets: goodwill, deferred tax, etc — 5-12 % of total
219        let unalloc_rate = Decimal::from(self.rng.random_range(5u32..=12)) / Decimal::from(100u32);
220        let unallocated_assets = consolidated_assets.abs() * unalloc_rate;
221
222        // Segment totals
223        let segment_revenue_total: Decimal = segments
224            .iter()
225            .map(|s| s.revenue_external + s.revenue_intersegment)
226            .sum();
227
228        // Intersegment eliminations are derived to enforce the exact identity:
229        //   segment_revenue_total + intersegment_eliminations = consolidated_revenue
230        // This avoids any decimal precision residual from proportion arithmetic.
231        let intersegment_eliminations = consolidated_revenue - segment_revenue_total;
232
233        let segment_profit_total: Decimal = segments.iter().map(|s| s.operating_profit).sum();
234        let segment_assets_total: Decimal = segments.iter().map(|s| s.total_assets).sum();
235
236        // Corporate overhead: derived to enforce the identity
237        //   segment_profit_total + corporate_overhead = consolidated_profit
238        let corporate_overhead = consolidated_profit - segment_profit_total;
239
240        let reconciliation = SegmentReconciliation {
241            period: period.to_string(),
242            company_code: company_code.to_string(),
243            segment_revenue_total,
244            intersegment_eliminations,
245            consolidated_revenue,
246            segment_profit_total,
247            corporate_overhead,
248            consolidated_profit,
249            segment_assets_total,
250            unallocated_assets,
251            consolidated_assets: segment_assets_total + unallocated_assets,
252        };
253
254        (segments, reconciliation)
255    }
256
257    /// Generate operating segments from actual journal entry data per entity.
258    ///
259    /// For each company, aggregates JEs by GL account prefix:
260    /// - Revenue (4xxx): net credits
261    /// - COGS (5xxx): net debits
262    /// - OpEx (6xxx + 7xxx): net debits
263    /// - Assets (1xxx): debit balances (snapshot from all entries)
264    /// - Liabilities (2xxx): credit balances
265    ///
266    /// # Arguments
267    /// * `journal_entries` – slice of all journal entries to aggregate
268    /// * `companies` – `(code, name)` tuples identifying each segment entity
269    /// * `period` – fiscal period label (e.g. `"2025-Q1"`)
270    /// * `ic_elimination_amount` – known intercompany elimination to embed in the reconciliation
271    ///
272    /// # Returns
273    /// `(segments, reconciliation)` where each segment corresponds to one company
274    /// and the reconciliation ties all segment totals to the consolidated figures.
275    pub fn generate_from_journal_entries(
276        &mut self,
277        journal_entries: &[JournalEntry],
278        companies: &[(String, String)],
279        period: &str,
280        ic_elimination_amount: Decimal,
281    ) -> (Vec<OperatingSegment>, SegmentReconciliation) {
282        let mut segments: Vec<OperatingSegment> = Vec::with_capacity(companies.len());
283
284        for (code, name) in companies {
285            // Aggregate amounts for this company across all its JE lines.
286            let mut revenue = Decimal::ZERO;
287            let mut cogs = Decimal::ZERO;
288            let mut opex = Decimal::ZERO;
289            let mut assets = Decimal::ZERO;
290            let mut liabilities = Decimal::ZERO;
291
292            for je in journal_entries
293                .iter()
294                .filter(|je| je.company_code() == code)
295            {
296                for line in &je.lines {
297                    let prefix = line.gl_account.chars().next().unwrap_or('0');
298                    match prefix {
299                        '4' => {
300                            // Revenue: net credits (credits - debits)
301                            revenue += line.credit_amount - line.debit_amount;
302                        }
303                        '5' => {
304                            // COGS: net debits (debits - credits)
305                            cogs += line.debit_amount - line.credit_amount;
306                        }
307                        '6' | '7' => {
308                            // OpEx: net debits (debits - credits)
309                            opex += line.debit_amount - line.credit_amount;
310                        }
311                        '1' => {
312                            // Assets: debit balances (debits - credits)
313                            assets += line.debit_amount - line.credit_amount;
314                        }
315                        '2' => {
316                            // Liabilities: credit balances (credits - debits)
317                            liabilities += line.credit_amount - line.debit_amount;
318                        }
319                        _ => {}
320                    }
321                }
322            }
323
324            let operating_profit = revenue - cogs - opex;
325
326            segments.push(OperatingSegment {
327                segment_id: self.uuid_factory.next().to_string(),
328                name: name.clone(),
329                segment_type: SegmentType::LegalEntity,
330                revenue_external: revenue,
331                revenue_intersegment: Decimal::ZERO,
332                operating_profit,
333                total_assets: assets,
334                total_liabilities: liabilities,
335                capital_expenditure: Decimal::ZERO,
336                depreciation_amortization: Decimal::ZERO,
337                period: period.to_string(),
338                company_code: code.clone(),
339            });
340        }
341
342        // Build reconciliation
343        let segment_revenue_total: Decimal = segments.iter().map(|s| s.revenue_external).sum();
344        let segment_profit_total: Decimal = segments.iter().map(|s| s.operating_profit).sum();
345        let segment_assets_total: Decimal = segments.iter().map(|s| s.total_assets).sum();
346        let corporate_overhead = Decimal::ZERO;
347        let unallocated_assets = Decimal::ZERO;
348
349        let reconciliation = SegmentReconciliation {
350            period: period.to_string(),
351            // Use the first company code as group code, or empty string if none
352            company_code: companies
353                .first()
354                .map(|(c, _)| c.clone())
355                .unwrap_or_default(),
356            segment_revenue_total,
357            intersegment_eliminations: ic_elimination_amount,
358            consolidated_revenue: segment_revenue_total - ic_elimination_amount,
359            segment_profit_total,
360            corporate_overhead,
361            consolidated_profit: segment_profit_total, // no corporate overhead allocation for JE-derived segments
362            segment_assets_total,
363            unallocated_assets,
364            consolidated_assets: segment_assets_total + unallocated_assets,
365        };
366
367        (segments, reconciliation)
368    }
369
370    // -----------------------------------------------------------------------
371    // Private helpers
372    // -----------------------------------------------------------------------
373
374    /// Generate a default list of 3 product-line segment names.
375    fn default_product_lines(&mut self) -> Vec<String> {
376        let options: &[&[&str]] = &[
377            &["Products", "Services", "Licensing"],
378            &["Hardware", "Software", "Support"],
379            &["Consumer", "Commercial", "Enterprise"],
380            &["Core", "Growth", "Emerging"],
381            &["Domestic", "International", "Other"],
382        ];
383        let idx = self.rng.random_range(0..options.len());
384        options[idx].iter().map(|s| s.to_string()).collect()
385    }
386
387    /// Generate n random proportions that sum to 1.
388    fn random_proportions(&mut self, n: usize) -> Vec<Decimal> {
389        // Draw n uniform samples and normalise
390        let raw: Vec<f64> = (0..n)
391            .map(|_| self.rng.random_range(1u32..=100) as f64)
392            .collect();
393        let total: f64 = raw.iter().sum();
394        raw.iter()
395            .map(|v| Decimal::from_f64_retain(v / total).unwrap_or(Decimal::ZERO))
396            .collect()
397    }
398
399    /// Generate per-segment profit multipliers (relative margin adjustments) around 1.0.
400    fn profit_multipliers(&mut self, n: usize) -> Vec<Decimal> {
401        (0..n)
402            .map(|_| {
403                let m = self.rng.random_range(70u32..=130);
404                Decimal::from(m) / Decimal::from(100u32)
405            })
406            .collect()
407    }
408}
409
410#[cfg(test)]
411#[allow(clippy::unwrap_used)]
412mod tests {
413    use super::*;
414
415    fn make_seeds(names: &[&str]) -> Vec<SegmentSeed> {
416        names
417            .iter()
418            .enumerate()
419            .map(|(i, n)| SegmentSeed {
420                code: format!("C{:03}", i + 1),
421                name: n.to_string(),
422                currency: "USD".to_string(),
423            })
424            .collect()
425    }
426
427    #[test]
428    fn test_segment_totals_match_consolidated_revenue() {
429        let mut gen = SegmentGenerator::new(42);
430        let seeds = make_seeds(&["North America", "Europe"]);
431
432        let rev = Decimal::from(1_000_000);
433        let profit = Decimal::from(150_000);
434        let assets = Decimal::from(5_000_000);
435
436        let (segments, recon) = gen.generate("GROUP", "2024-03", rev, profit, assets, &seeds, None);
437
438        assert!(!segments.is_empty());
439
440        // Σ external revenues = consolidated_revenue
441        let sum_ext: Decimal = segments.iter().map(|s| s.revenue_external).sum();
442        assert_eq!(
443            sum_ext, rev,
444            "Σ external revenue should equal consolidated_revenue"
445        );
446
447        // Reconciliation identity: segment_revenue_total + eliminations = consolidated_revenue
448        let computed = recon.segment_revenue_total + recon.intersegment_eliminations;
449        assert_eq!(
450            computed, recon.consolidated_revenue,
451            "segment_revenue_total + eliminations ≠ consolidated_revenue"
452        );
453    }
454
455    #[test]
456    fn test_reconciliation_profit_math() {
457        let mut gen = SegmentGenerator::new(99);
458        let seeds = make_seeds(&["Americas", "EMEA", "APAC"]);
459
460        let (_, recon) = gen.generate(
461            "CORP",
462            "2024-06",
463            Decimal::from(2_000_000),
464            Decimal::from(300_000),
465            Decimal::from(8_000_000),
466            &seeds,
467            None,
468        );
469
470        // consolidated_profit = segment_profit_total + corporate_overhead
471        assert_eq!(
472            recon.consolidated_profit,
473            recon.segment_profit_total + recon.corporate_overhead,
474            "Profit reconciliation identity failed"
475        );
476    }
477
478    #[test]
479    fn test_reconciliation_assets_math() {
480        let mut gen = SegmentGenerator::new(7);
481        let seeds = make_seeds(&["ProductA", "ProductB"]);
482
483        let (_, recon) = gen.generate(
484            "C001",
485            "2024-01",
486            Decimal::from(500_000),
487            Decimal::from(50_000),
488            Decimal::from(3_000_000),
489            &seeds,
490            None,
491        );
492
493        // consolidated_assets = segment_assets_total + unallocated_assets
494        assert_eq!(
495            recon.consolidated_assets,
496            recon.segment_assets_total + recon.unallocated_assets,
497            "Asset reconciliation identity failed"
498        );
499    }
500
501    #[test]
502    fn test_each_segment_has_positive_external_revenue() {
503        let mut gen = SegmentGenerator::new(42);
504        // Use seeds with all positive consolidated numbers
505        let seeds = make_seeds(&["SegA", "SegB", "SegC"]);
506        let rev = Decimal::from(3_000_000);
507        let profit = Decimal::from(600_000);
508        let assets = Decimal::from(10_000_000);
509
510        let (segments, _) = gen.generate("GRP", "2024-12", rev, profit, assets, &seeds, None);
511
512        for seg in &segments {
513            assert!(
514                seg.revenue_external >= Decimal::ZERO,
515                "Segment '{}' has negative external revenue: {}",
516                seg.name,
517                seg.revenue_external
518            );
519        }
520    }
521
522    #[test]
523    fn test_single_entity_uses_product_lines() {
524        let mut gen = SegmentGenerator::new(1234);
525        let seeds = make_seeds(&["OnlyEntity"]);
526
527        let (segments, _) = gen.generate(
528            "C001",
529            "2024-03",
530            Decimal::from(1_000_000),
531            Decimal::from(100_000),
532            Decimal::from(4_000_000),
533            &seeds,
534            None,
535        );
536
537        // With a single seed, product-line segments should be generated (≥ 2)
538        assert!(segments.len() >= 2, "Expected ≥ 2 product-line segments");
539        // All should be ProductLine type
540        for seg in &segments {
541            assert_eq!(seg.segment_type, SegmentType::ProductLine);
542        }
543    }
544
545    #[test]
546    fn test_multi_entity_uses_geographic_segments() {
547        let mut gen = SegmentGenerator::new(5678);
548        let seeds = make_seeds(&["US", "DE", "JP"]);
549
550        let (segments, _) = gen.generate(
551            "GROUP",
552            "2024-03",
553            Decimal::from(9_000_000),
554            Decimal::from(900_000),
555            Decimal::from(30_000_000),
556            &seeds,
557            None,
558        );
559
560        assert_eq!(segments.len(), 3);
561        for seg in &segments {
562            assert_eq!(seg.segment_type, SegmentType::Geographic);
563        }
564    }
565
566    #[test]
567    fn test_deterministic() {
568        let seeds = make_seeds(&["A", "B"]);
569        let rev = Decimal::from(1_000_000);
570        let profit = Decimal::from(200_000);
571        let assets = Decimal::from(5_000_000);
572
573        let (segs1, recon1) =
574            SegmentGenerator::new(42).generate("G", "2024-01", rev, profit, assets, &seeds, None);
575        let (segs2, recon2) =
576            SegmentGenerator::new(42).generate("G", "2024-01", rev, profit, assets, &seeds, None);
577
578        assert_eq!(segs1.len(), segs2.len());
579        for (a, b) in segs1.iter().zip(segs2.iter()) {
580            assert_eq!(a.segment_id, b.segment_id);
581            assert_eq!(a.revenue_external, b.revenue_external);
582        }
583        assert_eq!(recon1.consolidated_revenue, recon2.consolidated_revenue);
584        assert_eq!(recon1.segment_profit_total, recon2.segment_profit_total);
585    }
586}