Skip to main content

datasynth_generators/standards/
framework_reconciliation_generator.rs

1//! Framework Reconciliation Generator (US GAAP ↔ IFRS for Dual Reporting).
2//!
3//! Generates `FrameworkDifferenceRecord` entries describing how the
4//! same underlying economic transaction is measured differently under
5//! US GAAP vs IFRS, plus a top-level `FrameworkReconciliation` per
6//! entity summarizing cumulative NI / equity / asset differences.
7//!
8//! This generator only fires when `accounting_standards.framework`
9//! resolves to `DualReporting` AND
10//! `accounting_standards.generate_differences == true`. It does NOT
11//! perform real cross-standard accounting — it draws realistic-magnitude
12//! differences from a fixed distribution, stamps them against the
13//! entity's generated transactions, and reports the reconciled totals.
14//! This is sufficient for ML training sets and audit-procedure
15//! demonstrations; true standards-aware cross-framework measurement
16//! belongs in the individual standards generators (Lease v2 / ECL v2
17//! already produce framework-aware outputs).
18//!
19//! Deferred to v3.4+:
20//!   - True per-line tracing from underlying transactions to framework
21//!     differences (requires mapping revenue contracts / leases / ECL
22//!     models by item across frameworks).
23//!   - Deferred tax impact of temporary differences on the DTL/DTA
24//!     rollforward.
25
26use chrono::NaiveDate;
27use datasynth_core::utils::seeded_rng;
28use datasynth_standards::accounting::differences::{
29    DifferenceArea, DifferenceType, FinancialStatementImpact, FrameworkDifferenceRecord,
30    FrameworkReconciliation, ReconcilingItem,
31};
32use rand::prelude::*;
33use rand_chacha::ChaCha8Rng;
34use rand_distr::Normal;
35use rust_decimal::prelude::*;
36use rust_decimal::Decimal;
37
38/// Magnitude of differences to generate per entity.
39const DEFAULT_DIFFERENCE_COUNT: usize = 8;
40
41/// Canonical difference areas with realistic explanation texts.
42///
43/// Each tuple: (area, explanation, is-typically-temporary).
44const CANONICAL_DIFFERENCES: &[(DifferenceArea, &str, bool)] = &[
45    (
46        DifferenceArea::RevenueRecognition,
47        "Differences in point-in-time vs. over-time recognition criteria (ASC 606-10-25 vs IFRS 15.35)",
48        true,
49    ),
50    (
51        DifferenceArea::LeaseAccounting,
52        "Operating lease classification retained under ASC 842 while IFRS 16 applies a single-model on-balance-sheet approach",
53        true,
54    ),
55    (
56        DifferenceArea::InventoryCosting,
57        "US GAAP permits LIFO; IFRS (IAS 2) prohibits LIFO, requiring FIFO or weighted-average",
58        false,
59    ),
60    (
61        DifferenceArea::DevelopmentCosts,
62        "US GAAP expenses development costs as incurred (ASC 730); IFRS (IAS 38) capitalizes eligible development costs",
63        true,
64    ),
65    (
66        DifferenceArea::PropertyRevaluation,
67        "US GAAP uses cost model only; IFRS (IAS 16) permits revaluation model for PP&E",
68        true,
69    ),
70    (
71        DifferenceArea::Impairment,
72        "IFRS (IAS 36) permits reversal of impairment for non-financial assets (ex-goodwill); US GAAP prohibits reversal",
73        true,
74    ),
75    (
76        DifferenceArea::ContingentLiabilities,
77        "Recognition threshold differs: 'probable' (ASC 450) vs 'more likely than not' (IAS 37)",
78        true,
79    ),
80    (
81        DifferenceArea::ShareBasedPayment,
82        "Graded vesting attribution: US GAAP permits straight-line; IFRS requires accelerated method",
83        true,
84    ),
85    (
86        DifferenceArea::FinancialInstruments,
87        "Classification categories differ: ASC 326 vs IFRS 9 three-category model",
88        true,
89    ),
90    (
91        DifferenceArea::Consolidation,
92        "Control assessment differs: voting-interest model (US GAAP) vs single control model (IFRS 10)",
93        false,
94    ),
95    (
96        DifferenceArea::JointArrangements,
97        "Equity-method vs proportionate consolidation election for joint arrangements",
98        true,
99    ),
100    (
101        DifferenceArea::IncomeTaxes,
102        "Uncertain tax position recognition threshold (ASC 740 'more likely than not' vs IAS 12 probability-weighted expected value)",
103        true,
104    ),
105];
106
107/// Generator for framework-difference records and per-entity reconciliation.
108pub struct FrameworkReconciliationGenerator {
109    rng: ChaCha8Rng,
110}
111
112impl FrameworkReconciliationGenerator {
113    /// Create a new generator with the given seed.
114    pub fn new(seed: u64) -> Self {
115        Self {
116            rng: seeded_rng(seed, 0),
117        }
118    }
119
120    /// Generate framework-difference records + a top-level reconciliation
121    /// for a single entity.
122    pub fn generate(
123        &mut self,
124        company_code: &str,
125        period_date: NaiveDate,
126    ) -> (Vec<FrameworkDifferenceRecord>, FrameworkReconciliation) {
127        // Draw per-difference amount magnitudes from a log-normal-ish
128        // normal: mean 1_000_000, σ=1_500_000, truncated at 5_000.
129        let diff_dist = Normal::new(1_000_000.0_f64, 1_500_000.0_f64).expect("positive sigma");
130
131        let mut records = Vec::with_capacity(DEFAULT_DIFFERENCE_COUNT);
132        let mut cumulative_impact = FinancialStatementImpact::default();
133
134        // Shuffle CANONICAL_DIFFERENCES and take the first N.
135        let mut indices: Vec<usize> = (0..CANONICAL_DIFFERENCES.len()).collect();
136        indices.shuffle(&mut self.rng);
137        let count = DEFAULT_DIFFERENCE_COUNT.min(indices.len());
138
139        for idx in indices.into_iter().take(count) {
140            let (area, explanation, typically_temporary) = CANONICAL_DIFFERENCES[idx];
141
142            // Sample a US GAAP amount and derive IFRS amount with a
143            // realistic delta (±30% of US GAAP).
144            let us_gaap_raw = diff_dist.sample(&mut self.rng).abs().max(5_000.0_f64);
145            let delta_factor: f64 = self.rng.random_range(-0.30..0.30);
146            let ifrs_raw = us_gaap_raw * (1.0 + delta_factor);
147
148            let us_gaap_amount = Decimal::from_f64(us_gaap_raw)
149                .unwrap_or_else(|| Decimal::from(1_000_000))
150                .round_dp(2);
151            let ifrs_amount = Decimal::from_f64(ifrs_raw)
152                .unwrap_or_else(|| Decimal::from(1_000_000))
153                .round_dp(2);
154
155            let source_ref = format!("{company_code}-{area:?}-{:02}", idx + 1);
156            let description = format!("{area} difference — {}", self.rng.random::<u32>() % 1000);
157
158            let mut record = FrameworkDifferenceRecord::new(
159                company_code,
160                period_date,
161                area,
162                source_ref,
163                description,
164                us_gaap_amount,
165                ifrs_amount,
166            );
167            record.explanation = explanation.to_string();
168            record.difference_type = if typically_temporary {
169                DifferenceType::Temporary
170            } else {
171                DifferenceType::Permanent
172            };
173            // Classification strings — framework-specific line items.
174            record.us_gaap_classification = Self::us_gaap_classification(area);
175            record.ifrs_classification = Self::ifrs_classification(area);
176
177            // Financial-statement impact — sign and which line moves
178            // depends on the area.
179            let impact = Self::compute_impact(area, record.difference_amount);
180            cumulative_impact.assets_impact += impact.assets_impact;
181            cumulative_impact.liabilities_impact += impact.liabilities_impact;
182            cumulative_impact.equity_impact += impact.equity_impact;
183            cumulative_impact.revenue_impact += impact.revenue_impact;
184            cumulative_impact.expense_impact += impact.expense_impact;
185            cumulative_impact.net_income_impact += impact.net_income_impact;
186            record.financial_statement_impact = impact;
187
188            records.push(record);
189        }
190
191        // Build per-entity reconciliation summary from cumulative impact.
192        // Anchor US GAAP NI / equity / assets to plausible round-number
193        // baselines; IFRS figures are derived by applying the cumulative
194        // delta.
195        let us_gaap_ni = Decimal::from(10_000_000);
196        let ifrs_ni = us_gaap_ni + cumulative_impact.net_income_impact;
197        let us_gaap_equity = Decimal::from(50_000_000);
198        let ifrs_equity = us_gaap_equity + cumulative_impact.equity_impact;
199        let us_gaap_assets = Decimal::from(200_000_000);
200        let ifrs_assets = us_gaap_assets + cumulative_impact.assets_impact;
201
202        let reconciling_items: Vec<ReconcilingItem> = records
203            .iter()
204            .map(|r| ReconcilingItem {
205                description: r.description.clone(),
206                difference_area: r.difference_area,
207                net_income_impact: r.financial_statement_impact.net_income_impact,
208                equity_impact: r.financial_statement_impact.equity_impact,
209                asset_impact: r.financial_statement_impact.assets_impact,
210                liability_impact: r.financial_statement_impact.liabilities_impact,
211                explanation: r.explanation.clone(),
212            })
213            .collect();
214
215        let reconciliation = FrameworkReconciliation {
216            company_code: company_code.to_string(),
217            period_date,
218            us_gaap_net_income: us_gaap_ni,
219            ifrs_net_income: ifrs_ni,
220            us_gaap_equity,
221            ifrs_equity,
222            us_gaap_assets,
223            ifrs_assets,
224            reconciling_items,
225        };
226
227        (records, reconciliation)
228    }
229
230    fn us_gaap_classification(area: DifferenceArea) -> String {
231        match area {
232            DifferenceArea::RevenueRecognition => "Revenue — ASC 606",
233            DifferenceArea::LeaseAccounting => "Operating lease expense — ASC 842",
234            DifferenceArea::InventoryCosting => "Inventory (LIFO) — ASC 330",
235            DifferenceArea::DevelopmentCosts => "R&D expense — ASC 730",
236            DifferenceArea::PropertyRevaluation => "PP&E at cost — ASC 360",
237            DifferenceArea::Impairment => "Impairment loss — ASC 360 / ASC 350",
238            DifferenceArea::ContingentLiabilities => "Contingent loss — ASC 450",
239            DifferenceArea::ShareBasedPayment => "SBC expense — ASC 718",
240            DifferenceArea::FinancialInstruments => "Credit loss allowance — ASC 326",
241            DifferenceArea::Consolidation => "VIE consolidation — ASC 810",
242            DifferenceArea::JointArrangements => "Equity-method investment — ASC 323",
243            DifferenceArea::IncomeTaxes => "Uncertain tax position — ASC 740",
244            _ => "Other",
245        }
246        .to_string()
247    }
248
249    fn ifrs_classification(area: DifferenceArea) -> String {
250        match area {
251            DifferenceArea::RevenueRecognition => "Revenue — IFRS 15",
252            DifferenceArea::LeaseAccounting => "ROU asset + lease liability — IFRS 16",
253            DifferenceArea::InventoryCosting => "Inventory (FIFO/WA) — IAS 2",
254            DifferenceArea::DevelopmentCosts => "Intangible assets — IAS 38",
255            DifferenceArea::PropertyRevaluation => "PP&E (revaluation model) — IAS 16",
256            DifferenceArea::Impairment => "Impairment loss — IAS 36",
257            DifferenceArea::ContingentLiabilities => "Provision — IAS 37",
258            DifferenceArea::ShareBasedPayment => "SBC expense — IFRS 2",
259            DifferenceArea::FinancialInstruments => "Credit loss allowance — IFRS 9",
260            DifferenceArea::Consolidation => "Subsidiary consolidation — IFRS 10",
261            DifferenceArea::JointArrangements => "Joint venture — IFRS 11",
262            DifferenceArea::IncomeTaxes => "Uncertain tax position — IAS 12 / IFRIC 23",
263            _ => "Other",
264        }
265        .to_string()
266    }
267
268    fn compute_impact(area: DifferenceArea, delta: Decimal) -> FinancialStatementImpact {
269        // Simplified mapping from difference area to P&L / BS movement.
270        // The sign of `delta` carries through.
271        match area {
272            DifferenceArea::RevenueRecognition => FinancialStatementImpact {
273                revenue_impact: delta,
274                net_income_impact: delta,
275                equity_impact: delta,
276                ..Default::default()
277            },
278            DifferenceArea::LeaseAccounting => FinancialStatementImpact {
279                assets_impact: delta,
280                liabilities_impact: delta,
281                ..Default::default()
282            },
283            DifferenceArea::InventoryCosting | DifferenceArea::DevelopmentCosts => {
284                FinancialStatementImpact {
285                    assets_impact: delta,
286                    equity_impact: delta,
287                    net_income_impact: delta,
288                    ..Default::default()
289                }
290            }
291            DifferenceArea::PropertyRevaluation => FinancialStatementImpact {
292                assets_impact: delta,
293                equity_impact: delta,
294                ..Default::default()
295            },
296            DifferenceArea::Impairment => FinancialStatementImpact {
297                assets_impact: -delta,
298                expense_impact: delta,
299                net_income_impact: -delta,
300                equity_impact: -delta,
301                ..Default::default()
302            },
303            DifferenceArea::ContingentLiabilities => FinancialStatementImpact {
304                liabilities_impact: delta,
305                expense_impact: delta,
306                net_income_impact: -delta,
307                equity_impact: -delta,
308                ..Default::default()
309            },
310            _ => FinancialStatementImpact {
311                equity_impact: delta,
312                net_income_impact: delta,
313                ..Default::default()
314            },
315        }
316    }
317}
318
319#[cfg(test)]
320#[allow(clippy::unwrap_used)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn generates_expected_number_of_records() {
326        let mut gen = FrameworkReconciliationGenerator::new(42);
327        let (records, _recon) =
328            gen.generate("C001", NaiveDate::from_ymd_opt(2024, 12, 31).unwrap());
329        assert_eq!(records.len(), DEFAULT_DIFFERENCE_COUNT);
330    }
331
332    #[test]
333    fn reconciliation_includes_item_per_record() {
334        let mut gen = FrameworkReconciliationGenerator::new(7);
335        let (records, recon) = gen.generate("C001", NaiveDate::from_ymd_opt(2024, 12, 31).unwrap());
336        assert_eq!(recon.reconciling_items.len(), records.len());
337    }
338
339    #[test]
340    fn difference_amounts_are_signed() {
341        let mut gen = FrameworkReconciliationGenerator::new(13);
342        let (records, _) = gen.generate("C001", NaiveDate::from_ymd_opt(2024, 12, 31).unwrap());
343        // At least one record should have negative difference (IFRS < US GAAP).
344        let has_negative = records.iter().any(|r| r.difference_amount < Decimal::ZERO);
345        let has_positive = records.iter().any(|r| r.difference_amount > Decimal::ZERO);
346        assert!(
347            has_negative || has_positive,
348            "some differences must be non-zero"
349        );
350    }
351
352    #[test]
353    fn areas_are_distinct() {
354        let mut gen = FrameworkReconciliationGenerator::new(5);
355        let (records, _) = gen.generate("C001", NaiveDate::from_ymd_opt(2024, 12, 31).unwrap());
356        let mut areas: Vec<DifferenceArea> = records.iter().map(|r| r.difference_area).collect();
357        areas.sort_by_key(|a| format!("{a:?}"));
358        areas.dedup();
359        assert_eq!(
360            areas.len(),
361            records.len(),
362            "each generated record should cover a distinct area"
363        );
364    }
365}