datasynth_generators/standards/
provision_generator.rs1use chrono::NaiveDate;
43use datasynth_core::accounts::expense_accounts::INTEREST_EXPENSE;
44use datasynth_core::models::journal_entry::{
45 JournalEntry, JournalEntryHeader, JournalEntryLine, TransactionSource,
46};
47use datasynth_core::models::provision::{
48 ContingentLiability, ContingentProbability, Provision, ProvisionMovement, ProvisionType,
49};
50use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
51use rand::prelude::*;
52use rand_chacha::ChaCha8Rng;
53use rust_decimal::Decimal;
54use rust_decimal_macros::dec;
55
56const PROVISION_EXPENSE: &str = "6850";
62const PROVISION_LIABILITY: &str = "2450";
64
65const IFRS_THRESHOLD: f64 = 0.50;
69const US_GAAP_THRESHOLD: f64 = 0.75;
71
72#[derive(Debug, Default)]
78pub struct ProvisionSnapshot {
79 pub provisions: Vec<Provision>,
81 pub movements: Vec<ProvisionMovement>,
83 pub contingent_liabilities: Vec<ContingentLiability>,
85 pub journal_entries: Vec<JournalEntry>,
87}
88
89pub struct ProvisionGenerator {
95 uuid_factory: DeterministicUuidFactory,
96 rng: ChaCha8Rng,
97}
98
99impl ProvisionGenerator {
100 pub fn new(seed: u64) -> Self {
102 Self {
103 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::Provision),
104 rng: ChaCha8Rng::seed_from_u64(seed),
105 }
106 }
107
108 pub fn generate(
119 &mut self,
120 entity_code: &str,
121 currency: &str,
122 revenue_proxy: Decimal,
123 reporting_date: NaiveDate,
124 period_label: &str,
125 framework: &str,
126 ) -> ProvisionSnapshot {
127 let recognition_threshold = if framework == "IFRS" {
128 IFRS_THRESHOLD
129 } else {
130 US_GAAP_THRESHOLD
131 };
132
133 let provision_count = self.rng.random_range(3usize..=10);
135
136 let mut provisions: Vec<Provision> = Vec::with_capacity(provision_count);
137 let mut movements: Vec<ProvisionMovement> = Vec::with_capacity(provision_count);
138 let mut journal_entries: Vec<JournalEntry> = Vec::new();
139
140 for _ in 0..provision_count {
142 let (ptype, desc, prob, base_amount) =
143 self.sample_provision_type(revenue_proxy, reporting_date);
144
145 if prob <= recognition_threshold {
147 continue;
150 }
151
152 let best_estimate = round2(Decimal::try_from(base_amount).unwrap_or(dec!(10000)));
153 let range_low = round2(best_estimate * dec!(0.75));
154 let range_high = round2(best_estimate * dec!(1.50));
155
156 let months_to_settlement: i64 = self.rng.random_range(3i64..=60);
158 let is_long_term = months_to_settlement > 12;
159 let discount_rate = if is_long_term {
160 let rate_f: f64 = self.rng.random_range(0.03f64..=0.05);
161 Some(round6(Decimal::try_from(rate_f).unwrap_or(dec!(0.04))))
162 } else {
163 None
164 };
165
166 let utilization_date =
167 reporting_date + chrono::Months::new(months_to_settlement.unsigned_abs() as u32);
168
169 let prov_id = self.uuid_factory.next().to_string();
170 let provision = Provision {
171 id: prov_id.clone(),
172 entity_code: entity_code.to_string(),
173 provision_type: ptype,
174 description: desc.clone(),
175 best_estimate,
176 range_low,
177 range_high,
178 discount_rate,
179 expected_utilization_date: utilization_date,
180 framework: framework.to_string(),
181 currency: currency.to_string(),
182 };
183
184 let opening = Decimal::ZERO;
186 let additions = best_estimate;
187 let utilization_rate: f64 = self.rng.random_range(0.05f64..=0.15);
188 let utilizations =
189 round2(additions * Decimal::try_from(utilization_rate).unwrap_or(dec!(0.08)));
190 let reversal_rate: f64 = self.rng.random_range(0.0f64..=0.05);
191 let reversals =
192 round2(additions * Decimal::try_from(reversal_rate).unwrap_or(Decimal::ZERO));
193 let unwinding_of_discount = Decimal::ZERO;
195 let closing = (opening + additions - utilizations - reversals + unwinding_of_discount)
196 .max(Decimal::ZERO);
197
198 movements.push(ProvisionMovement {
199 provision_id: prov_id.clone(),
200 period: period_label.to_string(),
201 opening,
202 additions,
203 utilizations,
204 reversals,
205 unwinding_of_discount,
206 closing,
207 });
208
209 let recognition_amount = additions.max(Decimal::ZERO);
212 if recognition_amount > Decimal::ZERO {
213 let je = build_recognition_je(
214 &mut self.uuid_factory,
215 entity_code,
216 reporting_date,
217 recognition_amount,
218 &desc,
219 );
220 journal_entries.push(je);
221 }
222
223 provisions.push(provision);
224 }
225
226 let needed = 3usize.saturating_sub(provisions.len());
229 for i in 0..needed {
230 let base_amount = revenue_proxy * dec!(0.005); let best_estimate =
232 round2((base_amount + Decimal::from(i as u32 * 1000)).max(dec!(5000)));
233 let range_low = round2(best_estimate * dec!(0.75));
234 let range_high = round2(best_estimate * dec!(1.50));
235 let utilization_date =
236 reporting_date + chrono::Months::new(self.rng.random_range(6u32..=18));
237
238 let ptype = if i % 2 == 0 {
239 ProvisionType::Warranty
240 } else {
241 ProvisionType::LegalClaim
242 };
243 let desc = format!("{} provision — {} backfill", ptype, period_label);
244
245 let prov_id = self.uuid_factory.next().to_string();
246 let provision = Provision {
247 id: prov_id.clone(),
248 entity_code: entity_code.to_string(),
249 provision_type: ptype,
250 description: desc.clone(),
251 best_estimate,
252 range_low,
253 range_high,
254 discount_rate: None,
255 expected_utilization_date: utilization_date,
256 framework: framework.to_string(),
257 currency: currency.to_string(),
258 };
259
260 let opening = Decimal::ZERO;
261 let additions = best_estimate;
262 let utilizations = round2(additions * dec!(0.08));
263 let closing = (opening + additions - utilizations).max(Decimal::ZERO);
264
265 movements.push(ProvisionMovement {
266 provision_id: prov_id.clone(),
267 period: period_label.to_string(),
268 opening,
269 additions,
270 utilizations,
271 reversals: Decimal::ZERO,
272 unwinding_of_discount: Decimal::ZERO,
273 closing,
274 });
275
276 if additions > Decimal::ZERO {
277 let je = build_recognition_je(
278 &mut self.uuid_factory,
279 entity_code,
280 reporting_date,
281 additions,
282 &desc,
283 );
284 journal_entries.push(je);
285 }
286
287 provisions.push(provision);
288 }
289
290 let contingent_count = self.rng.random_range(1usize..=3);
292 let contingent_liabilities =
293 self.generate_contingent_liabilities(entity_code, currency, contingent_count);
294
295 ProvisionSnapshot {
296 provisions,
297 movements,
298 contingent_liabilities,
299 journal_entries,
300 }
301 }
302
303 fn sample_provision_type(
311 &mut self,
312 revenue_proxy: Decimal,
313 _reporting_date: NaiveDate,
314 ) -> (ProvisionType, String, f64, f64) {
315 let roll: f64 = self.rng.random();
318 let rev_f: f64 = revenue_proxy.try_into().unwrap_or(1_000_000.0);
319
320 let (ptype, base_amount) = if roll < 0.35 {
321 let pct: f64 = self.rng.random_range(0.02f64..=0.05);
323 (ProvisionType::Warranty, rev_f * pct)
324 } else if roll < 0.60 {
325 let amount: f64 = self.rng.random_range(50_000.0f64..=2_000_000.0);
327 (ProvisionType::LegalClaim, amount)
328 } else if roll < 0.75 {
329 let pct: f64 = self.rng.random_range(0.01f64..=0.03);
331 (ProvisionType::Restructuring, rev_f * pct)
332 } else if roll < 0.85 {
333 let amount: f64 = self.rng.random_range(100_000.0f64..=5_000_000.0);
335 (ProvisionType::EnvironmentalRemediation, amount)
336 } else if roll < 0.95 {
337 let pct: f64 = self.rng.random_range(0.005f64..=0.02);
339 (ProvisionType::OnerousContract, rev_f * pct)
340 } else {
341 let amount: f64 = self.rng.random_range(200_000.0f64..=10_000_000.0);
343 (ProvisionType::Decommissioning, amount)
344 };
345
346 let probability: f64 = self.rng.random_range(0.51f64..=0.99);
348
349 let desc = match ptype {
350 ProvisionType::Warranty => "Product warranty — current sales cohort".to_string(),
351 ProvisionType::LegalClaim => "Pending litigation claim".to_string(),
352 ProvisionType::Restructuring => {
353 "Restructuring programme — redundancy costs".to_string()
354 }
355 ProvisionType::EnvironmentalRemediation => {
356 "Environmental site remediation obligation".to_string()
357 }
358 ProvisionType::OnerousContract => "Onerous lease / supply contract".to_string(),
359 ProvisionType::Decommissioning => "Asset retirement obligation (ARO)".to_string(),
360 };
361
362 (ptype, desc, probability, base_amount)
363 }
364
365 fn generate_contingent_liabilities(
367 &mut self,
368 entity_code: &str,
369 currency: &str,
370 count: usize,
371 ) -> Vec<ContingentLiability> {
372 let natures = [
373 "Possible warranty claim from product recall investigation",
374 "Unresolved tax dispute with revenue authority",
375 "Environmental clean-up obligation under assessment",
376 "Patent infringement lawsuit — outcome uncertain",
377 "Customer class-action — settlement under negotiation",
378 "Supplier breach-of-contract claim",
379 ];
380
381 let mut result = Vec::with_capacity(count);
382 for i in 0..count {
383 let nature = natures[i % natures.len()].to_string();
384 let amount_f: f64 = self.rng.random_range(25_000.0f64..=500_000.0);
385 let estimated_amount =
386 Some(round2(Decimal::try_from(amount_f).unwrap_or(dec!(100_000))));
387
388 result.push(ContingentLiability {
389 id: self.uuid_factory.next().to_string(),
390 entity_code: entity_code.to_string(),
391 nature,
392 probability: ContingentProbability::Possible,
394 estimated_amount,
395 disclosure_only: true,
396 currency: currency.to_string(),
397 });
398 }
399 result
400 }
401}
402
403fn build_recognition_je(
414 _uuid_factory: &mut DeterministicUuidFactory,
415 entity_code: &str,
416 posting_date: NaiveDate,
417 amount: Decimal,
418 description: &str,
419) -> JournalEntry {
420 let mut header = JournalEntryHeader::new(entity_code.to_string(), posting_date);
421 header.header_text = Some(format!("Provision recognition — {description}"));
422 header.source = TransactionSource::Adjustment;
423 header.reference = Some("IAS37/ASC450-PROV".to_string());
424
425 let doc_id = header.document_id;
426 let mut je = JournalEntry::new(header);
427
428 let _ = INTEREST_EXPENSE;
430
431 je.add_line(JournalEntryLine::debit(
432 doc_id,
433 1,
434 PROVISION_EXPENSE.to_string(),
435 amount,
436 ));
437 je.add_line(JournalEntryLine::credit(
438 doc_id,
439 2,
440 PROVISION_LIABILITY.to_string(),
441 amount,
442 ));
443
444 je
445}
446
447#[allow(dead_code)]
454fn build_unwinding_je(
455 _uuid_factory: &mut DeterministicUuidFactory,
456 entity_code: &str,
457 posting_date: NaiveDate,
458 amount: Decimal,
459 provision_description: &str,
460) -> JournalEntry {
461 let mut header = JournalEntryHeader::new(entity_code.to_string(), posting_date);
462 header.header_text = Some(format!("Unwinding of discount — {provision_description}"));
463 header.source = TransactionSource::Adjustment;
464 header.reference = Some("IAS37-UNWIND".to_string());
465
466 let doc_id = header.document_id;
467 let mut je = JournalEntry::new(header);
468
469 je.add_line(JournalEntryLine::debit(
470 doc_id,
471 1,
472 INTEREST_EXPENSE.to_string(),
473 amount,
474 ));
475 je.add_line(JournalEntryLine::credit(
476 doc_id,
477 2,
478 PROVISION_LIABILITY.to_string(),
479 amount,
480 ));
481
482 je
483}
484
485#[inline]
490fn round2(d: Decimal) -> Decimal {
491 d.round_dp(2)
492}
493
494#[inline]
495fn round6(d: Decimal) -> Decimal {
496 d.round_dp(6)
497}