1use 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
38const DEFAULT_DIFFERENCE_COUNT: usize = 8;
40
41const 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
107pub struct FrameworkReconciliationGenerator {
109 rng: ChaCha8Rng,
110}
111
112impl FrameworkReconciliationGenerator {
113 pub fn new(seed: u64) -> Self {
115 Self {
116 rng: seeded_rng(seed, 0),
117 }
118 }
119
120 pub fn generate(
123 &mut self,
124 company_code: &str,
125 period_date: NaiveDate,
126 ) -> (Vec<FrameworkDifferenceRecord>, FrameworkReconciliation) {
127 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 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 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 record.us_gaap_classification = Self::us_gaap_classification(area);
175 record.ifrs_classification = Self::ifrs_classification(area);
176
177 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 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 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 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}