datasynth_generators/tax/
tax_provision_generator.rs1use chrono::NaiveDate;
9use rand::prelude::*;
10use rand_chacha::ChaCha8Rng;
11use rust_decimal::Decimal;
12use rust_decimal_macros::dec;
13
14use datasynth_core::models::TaxProvision;
15
16struct ReconciliationCandidate {
22 description: &'static str,
23 min_impact: Decimal,
25 max_impact: Decimal,
27}
28
29const CANDIDATES: &[ReconciliationCandidate] = &[
31 ReconciliationCandidate {
32 description: "State and local taxes",
33 min_impact: dec!(0.01),
34 max_impact: dec!(0.04),
35 },
36 ReconciliationCandidate {
37 description: "Permanent differences",
38 min_impact: dec!(-0.01),
39 max_impact: dec!(0.02),
40 },
41 ReconciliationCandidate {
42 description: "R&D tax credits",
43 min_impact: dec!(-0.02),
44 max_impact: dec!(-0.005),
45 },
46 ReconciliationCandidate {
47 description: "Foreign rate differential",
48 min_impact: dec!(-0.03),
49 max_impact: dec!(0.03),
50 },
51 ReconciliationCandidate {
52 description: "Stock compensation",
53 min_impact: dec!(-0.01),
54 max_impact: dec!(0.01),
55 },
56 ReconciliationCandidate {
57 description: "Valuation allowance change",
58 min_impact: dec!(-0.02),
59 max_impact: dec!(0.05),
60 },
61];
62
63pub struct TaxProvisionGenerator {
75 rng: ChaCha8Rng,
76 counter: u64,
77}
78
79impl TaxProvisionGenerator {
80 pub fn new(seed: u64) -> Self {
82 Self {
83 rng: ChaCha8Rng::seed_from_u64(seed),
84 counter: 0,
85 }
86 }
87
88 pub fn generate(
97 &mut self,
98 entity_id: &str,
99 period: NaiveDate,
100 pre_tax_income: Decimal,
101 statutory_rate: Decimal,
102 ) -> TaxProvision {
103 self.counter += 1;
104 let provision_id = format!("TXPROV-{:06}", self.counter);
105
106 let num_items = self.rng.gen_range(2..=5);
108 let mut selected_indices: Vec<usize> = (0..CANDIDATES.len()).collect();
109 selected_indices.shuffle(&mut self.rng);
110 selected_indices.truncate(num_items);
111 selected_indices.sort(); let mut total_impact = Decimal::ZERO;
114 let mut reconciliation_items: Vec<(&str, Decimal)> = Vec::new();
115
116 for &idx in &selected_indices {
117 let candidate = &CANDIDATES[idx];
118 let impact = self.random_decimal(candidate.min_impact, candidate.max_impact);
119 total_impact += impact;
120 reconciliation_items.push((candidate.description, impact));
121 }
122
123 let effective_rate = (statutory_rate + total_impact).round_dp(6);
124 let current_tax_expense = (pre_tax_income * effective_rate).round_dp(2);
125
126 let dta_pct = self.random_decimal(dec!(0.01), dec!(0.08));
129 let deferred_tax_asset = (pre_tax_income.abs() * dta_pct).round_dp(2);
130
131 let dtl_pct = self.random_decimal(dec!(0.01), dec!(0.06));
133 let deferred_tax_liability = (pre_tax_income.abs() * dtl_pct).round_dp(2);
134
135 let mut provision = TaxProvision::new(
136 provision_id,
137 entity_id,
138 period,
139 current_tax_expense,
140 deferred_tax_asset,
141 deferred_tax_liability,
142 statutory_rate,
143 effective_rate,
144 );
145
146 for (desc, impact) in &reconciliation_items {
147 provision = provision.with_reconciliation_item(*desc, *impact);
148 }
149
150 provision
151 }
152
153 fn random_decimal(&mut self, min: Decimal, max: Decimal) -> Decimal {
155 let range_f64 = (max - min).to_string().parse::<f64>().unwrap_or(0.0);
156 let min_f64 = min.to_string().parse::<f64>().unwrap_or(0.0);
157 let val = min_f64 + self.rng.gen::<f64>() * range_f64;
158 Decimal::try_from(val).unwrap_or(min).round_dp(6)
159 }
160}
161
162#[cfg(test)]
167#[allow(clippy::unwrap_used)]
168mod tests {
169 use super::*;
170
171 fn period_end() -> NaiveDate {
172 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()
173 }
174
175 #[test]
176 fn test_provision_calculation() {
177 let mut gen = TaxProvisionGenerator::new(42);
178 let provision = gen.generate("ENT-001", period_end(), dec!(1000000), dec!(0.21));
179
180 let total_impact: Decimal = provision
182 .rate_reconciliation
183 .iter()
184 .map(|r| r.rate_impact)
185 .sum();
186 let expected_effective = (dec!(0.21) + total_impact).round_dp(6);
187 assert_eq!(provision.effective_rate, expected_effective);
188
189 let expected_expense = (dec!(1000000) * provision.effective_rate).round_dp(2);
191 assert_eq!(provision.current_tax_expense, expected_expense);
192
193 assert_eq!(provision.statutory_rate, dec!(0.21));
195 }
196
197 #[test]
198 fn test_rate_reconciliation() {
199 let mut gen = TaxProvisionGenerator::new(42);
200 let provision = gen.generate("ENT-001", period_end(), dec!(500000), dec!(0.21));
201
202 assert!(
204 provision.rate_reconciliation.len() >= 2,
205 "Should have at least 2 items, got {}",
206 provision.rate_reconciliation.len()
207 );
208 assert!(
209 provision.rate_reconciliation.len() <= 5,
210 "Should have at most 5 items, got {}",
211 provision.rate_reconciliation.len()
212 );
213
214 let total_impact: Decimal = provision
216 .rate_reconciliation
217 .iter()
218 .map(|r| r.rate_impact)
219 .sum();
220 let diff = (provision.effective_rate - provision.statutory_rate).round_dp(6);
221 let impact_rounded = total_impact.round_dp(6);
222
223 let tolerance = dec!(0.000002);
225 assert!(
226 (diff - impact_rounded).abs() <= tolerance,
227 "Reconciliation items should sum to effective - statutory: diff={}, impact={}",
228 diff,
229 impact_rounded
230 );
231 }
232
233 #[test]
234 fn test_deferred_tax() {
235 let mut gen = TaxProvisionGenerator::new(42);
236 let provision = gen.generate("ENT-001", period_end(), dec!(2000000), dec!(0.21));
237
238 assert!(
240 provision.deferred_tax_asset > Decimal::ZERO,
241 "DTA should be positive: {}",
242 provision.deferred_tax_asset
243 );
244 assert!(
245 provision.deferred_tax_liability > Decimal::ZERO,
246 "DTL should be positive: {}",
247 provision.deferred_tax_liability
248 );
249
250 let pti = dec!(2000000);
252 assert!(
253 provision.deferred_tax_asset >= (pti * dec!(0.01)).round_dp(2),
254 "DTA too small"
255 );
256 assert!(
257 provision.deferred_tax_asset <= (pti * dec!(0.08)).round_dp(2),
258 "DTA too large"
259 );
260
261 assert!(
263 provision.deferred_tax_liability >= (pti * dec!(0.01)).round_dp(2),
264 "DTL too small"
265 );
266 assert!(
267 provision.deferred_tax_liability <= (pti * dec!(0.06)).round_dp(2),
268 "DTL too large"
269 );
270 }
271
272 #[test]
273 fn test_deterministic() {
274 let mut gen1 = TaxProvisionGenerator::new(999);
275 let p1 = gen1.generate("ENT-001", period_end(), dec!(750000), dec!(0.21));
276
277 let mut gen2 = TaxProvisionGenerator::new(999);
278 let p2 = gen2.generate("ENT-001", period_end(), dec!(750000), dec!(0.21));
279
280 assert_eq!(p1.id, p2.id);
281 assert_eq!(p1.current_tax_expense, p2.current_tax_expense);
282 assert_eq!(p1.effective_rate, p2.effective_rate);
283 assert_eq!(p1.statutory_rate, p2.statutory_rate);
284 assert_eq!(p1.deferred_tax_asset, p2.deferred_tax_asset);
285 assert_eq!(p1.deferred_tax_liability, p2.deferred_tax_liability);
286 assert_eq!(p1.rate_reconciliation.len(), p2.rate_reconciliation.len());
287 for (r1, r2) in p1
288 .rate_reconciliation
289 .iter()
290 .zip(p2.rate_reconciliation.iter())
291 {
292 assert_eq!(r1.description, r2.description);
293 assert_eq!(r1.rate_impact, r2.rate_impact);
294 }
295 }
296}