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