Skip to main content

datasynth_generators/tax/
tax_provision_generator.rs

1//! Tax Provision Generator (ASC 740 / IAS 12).
2//!
3//! Computes current and deferred income tax provisions for a reporting period.
4//! Generates rate reconciliation items that bridge from the statutory rate to
5//! the effective rate, and produces realistic deferred tax asset/liability
6//! balances from temporary differences.
7
8use chrono::NaiveDate;
9use datasynth_core::utils::seeded_rng;
10use rand::prelude::*;
11use rand_chacha::ChaCha8Rng;
12use rust_decimal::Decimal;
13use rust_decimal_macros::dec;
14
15use datasynth_core::models::TaxProvision;
16
17// ---------------------------------------------------------------------------
18// Rate reconciliation catalogue
19// ---------------------------------------------------------------------------
20
21/// A candidate reconciliation item with its description and rate-impact range.
22struct ReconciliationCandidate {
23    description: &'static str,
24    /// Minimum rate impact (inclusive).
25    min_impact: Decimal,
26    /// Maximum rate impact (inclusive).
27    max_impact: Decimal,
28}
29
30/// Pool of possible rate reconciliation items.
31const CANDIDATES: &[ReconciliationCandidate] = &[
32    ReconciliationCandidate {
33        description: "State and local taxes",
34        min_impact: dec!(0.01),
35        max_impact: dec!(0.04),
36    },
37    ReconciliationCandidate {
38        description: "Permanent differences",
39        min_impact: dec!(-0.01),
40        max_impact: dec!(0.02),
41    },
42    ReconciliationCandidate {
43        description: "R&D tax credits",
44        min_impact: dec!(-0.02),
45        max_impact: dec!(-0.005),
46    },
47    ReconciliationCandidate {
48        description: "Foreign rate differential",
49        min_impact: dec!(-0.03),
50        max_impact: dec!(0.03),
51    },
52    ReconciliationCandidate {
53        description: "Stock compensation",
54        min_impact: dec!(-0.01),
55        max_impact: dec!(0.01),
56    },
57    ReconciliationCandidate {
58        description: "Valuation allowance change",
59        min_impact: dec!(-0.02),
60        max_impact: dec!(0.05),
61    },
62];
63
64// ---------------------------------------------------------------------------
65// Generator
66// ---------------------------------------------------------------------------
67
68/// Generates income tax provisions under ASC 740 / IAS 12.
69///
70/// Given pre-tax income and a statutory rate, the generator:
71/// 1. Selects 2-5 rate reconciliation items from the candidate pool.
72/// 2. Computes the effective rate as `statutory_rate + sum(reconciliation impacts)`.
73/// 3. Computes `current_tax_expense = pre_tax_income * effective_rate`.
74/// 4. Generates realistic deferred tax asset and liability balances.
75pub struct TaxProvisionGenerator {
76    rng: ChaCha8Rng,
77    counter: u64,
78}
79
80impl TaxProvisionGenerator {
81    /// Creates a new tax provision generator with the given deterministic seed.
82    pub fn new(seed: u64) -> Self {
83        Self {
84            rng: seeded_rng(seed, 0),
85            counter: 0,
86        }
87    }
88
89    /// Generate a tax provision for a period.
90    ///
91    /// # Arguments
92    ///
93    /// * `entity_id` - Legal entity identifier.
94    /// * `period` - Period end date.
95    /// * `pre_tax_income` - Pre-tax income from financial statements.
96    /// * `statutory_rate` - Statutory corporate tax rate (e.g., `0.21` for US).
97    pub fn generate(
98        &mut self,
99        entity_id: &str,
100        period: NaiveDate,
101        pre_tax_income: Decimal,
102        statutory_rate: Decimal,
103    ) -> TaxProvision {
104        self.counter += 1;
105        let provision_id = format!("TXPROV-{:06}", self.counter);
106
107        // Select 2-5 reconciliation items
108        let num_items = self.rng.random_range(2..=5);
109        let mut selected_indices: Vec<usize> = (0..CANDIDATES.len()).collect();
110        selected_indices.shuffle(&mut self.rng);
111        selected_indices.truncate(num_items);
112        selected_indices.sort(); // stable ordering for determinism after shuffle
113
114        let mut total_impact = Decimal::ZERO;
115        let mut reconciliation_items: Vec<(&str, Decimal)> = Vec::new();
116
117        for &idx in &selected_indices {
118            let candidate = &CANDIDATES[idx];
119            let impact = self.random_decimal(candidate.min_impact, candidate.max_impact);
120            total_impact += impact;
121            reconciliation_items.push((candidate.description, impact));
122        }
123
124        let effective_rate = (statutory_rate + total_impact).round_dp(6);
125        let current_tax_expense = (pre_tax_income * effective_rate).round_dp(2);
126
127        // Generate deferred tax balances (random realistic amounts)
128        // DTA: typically 1-8% of pre_tax_income (from timing differences, NOL carryforwards)
129        let dta_pct = self.random_decimal(dec!(0.01), dec!(0.08));
130        let deferred_tax_asset = (pre_tax_income.abs() * dta_pct).round_dp(2);
131
132        // DTL: typically 1-6% of pre_tax_income (from depreciation timing, etc.)
133        let dtl_pct = self.random_decimal(dec!(0.01), dec!(0.06));
134        let deferred_tax_liability = (pre_tax_income.abs() * dtl_pct).round_dp(2);
135
136        let mut provision = TaxProvision::new(
137            provision_id,
138            entity_id,
139            period,
140            current_tax_expense,
141            deferred_tax_asset,
142            deferred_tax_liability,
143            statutory_rate,
144            effective_rate,
145        );
146
147        for (desc, impact) in &reconciliation_items {
148            provision = provision.with_reconciliation_item(*desc, *impact);
149        }
150
151        provision
152    }
153
154    /// Generates a random decimal between `min` and `max` (inclusive).
155    fn random_decimal(&mut self, min: Decimal, max: Decimal) -> Decimal {
156        let range_f64 = (max - min).to_string().parse::<f64>().unwrap_or(0.0);
157        let min_f64 = min.to_string().parse::<f64>().unwrap_or(0.0);
158        let val = min_f64 + self.rng.random::<f64>() * range_f64;
159        Decimal::try_from(val).unwrap_or(min).round_dp(6)
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Tests
165// ---------------------------------------------------------------------------
166
167#[cfg(test)]
168#[allow(clippy::unwrap_used)]
169mod tests {
170    use super::*;
171
172    fn period_end() -> NaiveDate {
173        NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()
174    }
175
176    #[test]
177    fn test_provision_calculation() {
178        let mut gen = TaxProvisionGenerator::new(42);
179        let provision = gen.generate("ENT-001", period_end(), dec!(1000000), dec!(0.21));
180
181        // Effective rate should be statutory_rate + sum of reconciliation impacts
182        let total_impact: Decimal = provision
183            .rate_reconciliation
184            .iter()
185            .map(|r| r.rate_impact)
186            .sum();
187        let expected_effective = (dec!(0.21) + total_impact).round_dp(6);
188        assert_eq!(provision.effective_rate, expected_effective);
189
190        // Current tax expense = pre_tax_income * effective_rate
191        let expected_expense = (dec!(1000000) * provision.effective_rate).round_dp(2);
192        assert_eq!(provision.current_tax_expense, expected_expense);
193
194        // Statutory rate preserved
195        assert_eq!(provision.statutory_rate, dec!(0.21));
196    }
197
198    #[test]
199    fn test_rate_reconciliation() {
200        let mut gen = TaxProvisionGenerator::new(42);
201        let provision = gen.generate("ENT-001", period_end(), dec!(500000), dec!(0.21));
202
203        // Should have 2-5 reconciliation items
204        assert!(
205            provision.rate_reconciliation.len() >= 2,
206            "Should have at least 2 items, got {}",
207            provision.rate_reconciliation.len()
208        );
209        assert!(
210            provision.rate_reconciliation.len() <= 5,
211            "Should have at most 5 items, got {}",
212            provision.rate_reconciliation.len()
213        );
214
215        // Sum of impacts should equal effective_rate - statutory_rate
216        let total_impact: Decimal = provision
217            .rate_reconciliation
218            .iter()
219            .map(|r| r.rate_impact)
220            .sum();
221        let diff = (provision.effective_rate - provision.statutory_rate).round_dp(6);
222        let impact_rounded = total_impact.round_dp(6);
223
224        // Allow small tolerance for floating-point → decimal conversion
225        let tolerance = dec!(0.000002);
226        assert!(
227            (diff - impact_rounded).abs() <= tolerance,
228            "Reconciliation items should sum to effective - statutory: diff={}, impact={}",
229            diff,
230            impact_rounded
231        );
232    }
233
234    #[test]
235    fn test_deferred_tax() {
236        let mut gen = TaxProvisionGenerator::new(42);
237        let provision = gen.generate("ENT-001", period_end(), dec!(2000000), dec!(0.21));
238
239        // Deferred tax asset and liability should both be positive
240        assert!(
241            provision.deferred_tax_asset > Decimal::ZERO,
242            "DTA should be positive: {}",
243            provision.deferred_tax_asset
244        );
245        assert!(
246            provision.deferred_tax_liability > Decimal::ZERO,
247            "DTL should be positive: {}",
248            provision.deferred_tax_liability
249        );
250
251        // DTA should be between 1-8% of pre_tax_income
252        let pti = dec!(2000000);
253        assert!(
254            provision.deferred_tax_asset >= (pti * dec!(0.01)).round_dp(2),
255            "DTA too small"
256        );
257        assert!(
258            provision.deferred_tax_asset <= (pti * dec!(0.08)).round_dp(2),
259            "DTA too large"
260        );
261
262        // DTL should be between 1-6% of pre_tax_income
263        assert!(
264            provision.deferred_tax_liability >= (pti * dec!(0.01)).round_dp(2),
265            "DTL too small"
266        );
267        assert!(
268            provision.deferred_tax_liability <= (pti * dec!(0.06)).round_dp(2),
269            "DTL too large"
270        );
271    }
272
273    #[test]
274    fn test_deterministic() {
275        let mut gen1 = TaxProvisionGenerator::new(999);
276        let p1 = gen1.generate("ENT-001", period_end(), dec!(750000), dec!(0.21));
277
278        let mut gen2 = TaxProvisionGenerator::new(999);
279        let p2 = gen2.generate("ENT-001", period_end(), dec!(750000), dec!(0.21));
280
281        assert_eq!(p1.id, p2.id);
282        assert_eq!(p1.current_tax_expense, p2.current_tax_expense);
283        assert_eq!(p1.effective_rate, p2.effective_rate);
284        assert_eq!(p1.statutory_rate, p2.statutory_rate);
285        assert_eq!(p1.deferred_tax_asset, p2.deferred_tax_asset);
286        assert_eq!(p1.deferred_tax_liability, p2.deferred_tax_liability);
287        assert_eq!(p1.rate_reconciliation.len(), p2.rate_reconciliation.len());
288        for (r1, r2) in p1
289            .rate_reconciliation
290            .iter()
291            .zip(p2.rate_reconciliation.iter())
292        {
293            assert_eq!(r1.description, r2.description);
294            assert_eq!(r1.rate_impact, r2.rate_impact);
295        }
296    }
297}