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(
122 &mut self,
123 entity_code: &str,
124 currency: &str,
125 revenue_proxy: Decimal,
126 reporting_date: NaiveDate,
127 period_label: &str,
128 framework: &str,
129 prior_opening: Option<Decimal>,
130 ) -> ProvisionSnapshot {
131 let recognition_threshold = if framework == "IFRS" {
132 IFRS_THRESHOLD
133 } else {
134 US_GAAP_THRESHOLD
135 };
136
137 let provision_count = self.rng.random_range(3usize..=10);
139
140 let mut provisions: Vec<Provision> = Vec::with_capacity(provision_count);
141 let mut movements: Vec<ProvisionMovement> = Vec::with_capacity(provision_count);
142 let mut journal_entries: Vec<JournalEntry> = Vec::new();
143
144 for _ in 0..provision_count {
146 let (ptype, desc, prob, base_amount) =
147 self.sample_provision_type(revenue_proxy, reporting_date);
148
149 if prob <= recognition_threshold {
151 continue;
154 }
155
156 let best_estimate = round2(Decimal::try_from(base_amount).unwrap_or(dec!(10000)));
157 let range_low = round2(best_estimate * dec!(0.75));
158 let range_high = round2(best_estimate * dec!(1.50));
159
160 let months_to_settlement: i64 = self.rng.random_range(3i64..=60);
162 let is_long_term = months_to_settlement > 12;
163 let discount_rate = if is_long_term {
164 let rate_f: f64 = self.rng.random_range(0.03f64..=0.05);
165 Some(round6(Decimal::try_from(rate_f).unwrap_or(dec!(0.04))))
166 } else {
167 None
168 };
169
170 let utilization_date =
171 reporting_date + chrono::Months::new(months_to_settlement.unsigned_abs() as u32);
172
173 let prov_id = self.uuid_factory.next().to_string();
174 let provision = Provision {
175 id: prov_id.clone(),
176 entity_code: entity_code.to_string(),
177 provision_type: ptype,
178 description: desc.clone(),
179 best_estimate,
180 range_low,
181 range_high,
182 discount_rate,
183 expected_utilization_date: utilization_date,
184 framework: framework.to_string(),
185 currency: currency.to_string(),
186 };
187
188 let opening = Decimal::ZERO;
190 let additions = best_estimate;
191 let utilization_rate: f64 = self.rng.random_range(0.05f64..=0.15);
192 let utilizations =
193 round2(additions * Decimal::try_from(utilization_rate).unwrap_or(dec!(0.08)));
194 let reversal_rate: f64 = self.rng.random_range(0.0f64..=0.05);
195 let reversals =
196 round2(additions * Decimal::try_from(reversal_rate).unwrap_or(Decimal::ZERO));
197 let unwinding_of_discount =
201 if let (Some(prior_bal), Some(rate)) = (prior_opening, discount_rate) {
202 round2((prior_bal * rate).max(Decimal::ZERO))
204 } else {
205 Decimal::ZERO
206 };
207 let closing = (opening + additions - utilizations - reversals + unwinding_of_discount)
208 .max(Decimal::ZERO);
209
210 movements.push(ProvisionMovement {
211 provision_id: prov_id.clone(),
212 period: period_label.to_string(),
213 opening,
214 additions,
215 utilizations,
216 reversals,
217 unwinding_of_discount,
218 closing,
219 });
220
221 let recognition_amount = additions.max(Decimal::ZERO);
224 if recognition_amount > Decimal::ZERO {
225 let je = build_recognition_je(
226 &mut self.uuid_factory,
227 entity_code,
228 reporting_date,
229 recognition_amount,
230 &desc,
231 );
232 journal_entries.push(je);
233 }
234
235 provisions.push(provision);
236 }
237
238 let needed = 3usize.saturating_sub(provisions.len());
241 for i in 0..needed {
242 let base_amount = revenue_proxy * dec!(0.005); let best_estimate =
244 round2((base_amount + Decimal::from(i as u32 * 1000)).max(dec!(5000)));
245 let range_low = round2(best_estimate * dec!(0.75));
246 let range_high = round2(best_estimate * dec!(1.50));
247 let utilization_date =
248 reporting_date + chrono::Months::new(self.rng.random_range(6u32..=18));
249
250 let ptype = if i % 2 == 0 {
251 ProvisionType::Warranty
252 } else {
253 ProvisionType::LegalClaim
254 };
255 let desc = format!("{} provision — {} backfill", ptype, period_label);
256
257 let prov_id = self.uuid_factory.next().to_string();
258 let provision = Provision {
259 id: prov_id.clone(),
260 entity_code: entity_code.to_string(),
261 provision_type: ptype,
262 description: desc.clone(),
263 best_estimate,
264 range_low,
265 range_high,
266 discount_rate: None,
267 expected_utilization_date: utilization_date,
268 framework: framework.to_string(),
269 currency: currency.to_string(),
270 };
271
272 let opening = Decimal::ZERO;
273 let additions = best_estimate;
274 let utilizations = round2(additions * dec!(0.08));
275 let closing = (opening + additions - utilizations).max(Decimal::ZERO);
276
277 movements.push(ProvisionMovement {
278 provision_id: prov_id.clone(),
279 period: period_label.to_string(),
280 opening,
281 additions,
282 utilizations,
283 reversals: Decimal::ZERO,
284 unwinding_of_discount: Decimal::ZERO,
285 closing,
286 });
287
288 if additions > Decimal::ZERO {
289 let je = build_recognition_je(
290 &mut self.uuid_factory,
291 entity_code,
292 reporting_date,
293 additions,
294 &desc,
295 );
296 journal_entries.push(je);
297 }
298
299 provisions.push(provision);
300 }
301
302 let contingent_count = self.rng.random_range(1usize..=3);
304 let contingent_liabilities =
305 self.generate_contingent_liabilities(entity_code, currency, contingent_count);
306
307 ProvisionSnapshot {
308 provisions,
309 movements,
310 contingent_liabilities,
311 journal_entries,
312 }
313 }
314
315 fn sample_provision_type(
323 &mut self,
324 revenue_proxy: Decimal,
325 _reporting_date: NaiveDate,
326 ) -> (ProvisionType, String, f64, f64) {
327 let roll: f64 = self.rng.random();
330 let rev_f: f64 = revenue_proxy.try_into().unwrap_or(1_000_000.0);
331
332 let (ptype, base_amount) = if roll < 0.35 {
333 let pct: f64 = self.rng.random_range(0.02f64..=0.05);
335 (ProvisionType::Warranty, rev_f * pct)
336 } else if roll < 0.60 {
337 let amount: f64 = self.rng.random_range(50_000.0f64..=2_000_000.0);
339 (ProvisionType::LegalClaim, amount)
340 } else if roll < 0.75 {
341 let pct: f64 = self.rng.random_range(0.01f64..=0.03);
343 (ProvisionType::Restructuring, rev_f * pct)
344 } else if roll < 0.85 {
345 let amount: f64 = self.rng.random_range(100_000.0f64..=5_000_000.0);
347 (ProvisionType::EnvironmentalRemediation, amount)
348 } else if roll < 0.95 {
349 let pct: f64 = self.rng.random_range(0.005f64..=0.02);
351 (ProvisionType::OnerousContract, rev_f * pct)
352 } else {
353 let amount: f64 = self.rng.random_range(200_000.0f64..=10_000_000.0);
355 (ProvisionType::Decommissioning, amount)
356 };
357
358 let probability: f64 = self.rng.random_range(0.51f64..=0.99);
360
361 let desc = match ptype {
362 ProvisionType::Warranty => "Product warranty — current sales cohort".to_string(),
363 ProvisionType::LegalClaim => "Pending litigation claim".to_string(),
364 ProvisionType::Restructuring => {
365 "Restructuring programme — redundancy costs".to_string()
366 }
367 ProvisionType::EnvironmentalRemediation => {
368 "Environmental site remediation obligation".to_string()
369 }
370 ProvisionType::OnerousContract => "Onerous lease / supply contract".to_string(),
371 ProvisionType::Decommissioning => "Asset retirement obligation (ARO)".to_string(),
372 };
373
374 (ptype, desc, probability, base_amount)
375 }
376
377 fn generate_contingent_liabilities(
379 &mut self,
380 entity_code: &str,
381 currency: &str,
382 count: usize,
383 ) -> Vec<ContingentLiability> {
384 let natures = [
385 "Possible warranty claim from product recall investigation",
386 "Unresolved tax dispute with revenue authority",
387 "Environmental clean-up obligation under assessment",
388 "Patent infringement lawsuit — outcome uncertain",
389 "Customer class-action — settlement under negotiation",
390 "Supplier breach-of-contract claim",
391 ];
392
393 let mut result = Vec::with_capacity(count);
394 for i in 0..count {
395 let nature = natures[i % natures.len()].to_string();
396 let amount_f: f64 = self.rng.random_range(25_000.0f64..=500_000.0);
397 let estimated_amount =
398 Some(round2(Decimal::try_from(amount_f).unwrap_or(dec!(100_000))));
399
400 result.push(ContingentLiability {
401 id: self.uuid_factory.next().to_string(),
402 entity_code: entity_code.to_string(),
403 nature,
404 probability: ContingentProbability::Possible,
406 estimated_amount,
407 disclosure_only: true,
408 currency: currency.to_string(),
409 });
410 }
411 result
412 }
413}
414
415fn build_recognition_je(
426 _uuid_factory: &mut DeterministicUuidFactory,
427 entity_code: &str,
428 posting_date: NaiveDate,
429 amount: Decimal,
430 description: &str,
431) -> JournalEntry {
432 let mut header = JournalEntryHeader::new(entity_code.to_string(), posting_date);
433 header.header_text = Some(format!("Provision recognition — {description}"));
434 header.source = TransactionSource::Adjustment;
435 header.reference = Some("IAS37/ASC450-PROV".to_string());
436
437 let doc_id = header.document_id;
438 let mut je = JournalEntry::new(header);
439
440 let _ = INTEREST_EXPENSE;
442
443 je.add_line(JournalEntryLine::debit(
444 doc_id,
445 1,
446 PROVISION_EXPENSE.to_string(),
447 amount,
448 ));
449 je.add_line(JournalEntryLine::credit(
450 doc_id,
451 2,
452 PROVISION_LIABILITY.to_string(),
453 amount,
454 ));
455
456 je
457}
458
459#[allow(dead_code)]
466fn build_unwinding_je(
467 _uuid_factory: &mut DeterministicUuidFactory,
468 entity_code: &str,
469 posting_date: NaiveDate,
470 amount: Decimal,
471 provision_description: &str,
472) -> JournalEntry {
473 let mut header = JournalEntryHeader::new(entity_code.to_string(), posting_date);
474 header.header_text = Some(format!("Unwinding of discount — {provision_description}"));
475 header.source = TransactionSource::Adjustment;
476 header.reference = Some("IAS37-UNWIND".to_string());
477
478 let doc_id = header.document_id;
479 let mut je = JournalEntry::new(header);
480
481 je.add_line(JournalEntryLine::debit(
482 doc_id,
483 1,
484 INTEREST_EXPENSE.to_string(),
485 amount,
486 ));
487 je.add_line(JournalEntryLine::credit(
488 doc_id,
489 2,
490 PROVISION_LIABILITY.to_string(),
491 amount,
492 ));
493
494 je
495}
496
497#[inline]
502fn round2(d: Decimal) -> Decimal {
503 d.round_dp(2)
504}
505
506#[inline]
507fn round6(d: Decimal) -> Decimal {
508 d.round_dp(6)
509}