Skip to main content

datasynth_generators/standards/
provision_generator.rs

1//! Provisions and contingencies generator — IAS 37 / ASC 450.
2//!
3//! Generates recognised provisions, provision movement roll-forwards,
4//! contingent liability disclosures, and associated journal entries for a
5//! reporting entity.
6//!
7//! # Generation logic
8//!
9//! 1. **Provision count** — 3–10 provisions per entity, weighted by industry.
10//!    Manufacturing / energy entities tend to carry more environmental and
11//!    decommissioning provisions; retail entities carry more warranty provisions.
12//!
13//! 2. **Framework-aware recognition threshold**
14//!    - IFRS (IAS 37): recognise when probability > 50%.
15//!    - US GAAP (ASC 450): recognise when probability > 75%.
16//!
17//!    Items that fall below the recognition threshold become contingent liabilities.
18//!
19//! 3. **Provision measurement**
20//!    - `best_estimate`: sampled from a log-normal distribution calibrated to the
21//!      provision type and a revenue proxy.
22//!    - `range_low` = 75% of best estimate; `range_high` = 150%.
23//!    - Long-term provisions (expected settlement > 12 months) are discounted at
24//!      a rate of 3–5%.
25//!
26//! 4. **Provision movement** (first-period run)
27//!    - Opening = 0 (fresh start).
28//!    - Additions = best_estimate (provision first recognised).
29//!    - Utilizations = 5–15% of additions (partial settlement in period).
30//!    - Reversals = 0–5% of additions (minor re-estimates).
31//!    - Unwinding of discount = discount_rate × opening (zero for first period).
32//!    - Closing = opening + additions − utilizations − reversals + unwinding.
33//!
34//! 5. **Journal entries**
35//!    - Initial recognition:
36//!      `DR Provision Expense (6850) / CR Provision Liability (2450)`
37//!    - Unwinding of discount (long-term only, non-zero when opening > 0):
38//!      `DR Finance Cost (7100) / CR Provision Liability (2450)`
39//!
40//! 6. **Contingent liabilities** — 1–3 items per entity, always `disclosure_only = true`.
41
42use 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
56// ============================================================================
57// GL account constants (provision-specific)
58// ============================================================================
59
60/// Provision / impairment expense (operating).
61const PROVISION_EXPENSE: &str = "6850";
62/// Provision liability — current and non-current (balance-sheet).
63const PROVISION_LIABILITY: &str = "2450";
64
65// ============================================================================
66// IFRS recognition threshold (probability > 50%)
67// ============================================================================
68const IFRS_THRESHOLD: f64 = 0.50;
69/// US GAAP recognition threshold (probability > 75%)
70const US_GAAP_THRESHOLD: f64 = 0.75;
71
72// ============================================================================
73// Snapshot
74// ============================================================================
75
76/// All outputs from one provision generation run.
77#[derive(Debug, Default)]
78pub struct ProvisionSnapshot {
79    /// Recognised provisions (balance-sheet items).
80    pub provisions: Vec<Provision>,
81    /// Provision movement roll-forwards (one per provision).
82    pub movements: Vec<ProvisionMovement>,
83    /// Contingent liabilities (disclosed, not recognised).
84    pub contingent_liabilities: Vec<ContingentLiability>,
85    /// Journal entries (provision expense + unwinding of discount).
86    pub journal_entries: Vec<JournalEntry>,
87}
88
89// ============================================================================
90// Generator
91// ============================================================================
92
93/// Generates provisions and contingencies data for a reporting entity.
94pub struct ProvisionGenerator {
95    uuid_factory: DeterministicUuidFactory,
96    rng: ChaCha8Rng,
97}
98
99impl ProvisionGenerator {
100    /// Create a new generator with a deterministic seed.
101    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    /// Generate provisions and contingencies for one entity.
109    ///
110    /// # Parameters
111    /// - `entity_code`: company / entity identifier
112    /// - `currency`: reporting currency code (e.g. `"USD"`)
113    /// - `revenue_proxy`: approximate annual revenue used to size warranty provisions
114    /// - `reporting_date`: balance-sheet date (provisions dated to this period)
115    /// - `period_label`: label for the movement roll-forward (e.g. `"FY2024"`)
116    /// - `framework`: `"IFRS"` or `"US_GAAP"`
117    /// - `industry`: optional industry tag for provision-type weights
118    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        // ---- Step 1: determine provision count (3–10) -----------------------
134        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        // ---- Step 2: generate each provision --------------------------------
141        for _ in 0..provision_count {
142            let (ptype, desc, prob, base_amount) =
143                self.sample_provision_type(revenue_proxy, reporting_date);
144
145            // Framework-aware: only recognise if above threshold
146            if prob <= recognition_threshold {
147                // Below recognition threshold — will be collected as contingent
148                // liability below (if Possible, not Remote).
149                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            // Long-term provisions (> 12 months): apply discounting
157            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            // ---- Step 3: movement roll-forward (first-period run) -----------
185            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            // Unwinding = 0 for first-period (opening = 0).
194            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            // ---- Step 4: journal entries ------------------------------------
210            // Recognition JE: DR Provision Expense / CR Provision Liability
211            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        // Ensure we have at least 3 provisions even if probability sampling
227        // removed some items — backfill with warranty/legal if needed.
228        let needed = 3usize.saturating_sub(provisions.len());
229        for i in 0..needed {
230            let base_amount = revenue_proxy * dec!(0.005); // 0.5% of revenue
231            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        // ---- Step 5: contingent liabilities (1–3, disclosure only) ----------
291        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    // -------------------------------------------------------------------------
304    // Helpers
305    // -------------------------------------------------------------------------
306
307    /// Sample a provision type with associated description and probability.
308    ///
309    /// Returns `(ProvisionType, description, probability, base_amount_f64)`.
310    fn sample_provision_type(
311        &mut self,
312        revenue_proxy: Decimal,
313        _reporting_date: NaiveDate,
314    ) -> (ProvisionType, String, f64, f64) {
315        // Weighted selection: Warranty 35%, Legal 25%, Restructuring 15%,
316        // Environmental 10%, Onerous 10%, Decommissioning 5%.
317        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            // Warranty: 2–5% of revenue
322            let pct: f64 = self.rng.random_range(0.02f64..=0.05);
323            (ProvisionType::Warranty, rev_f * pct)
324        } else if roll < 0.60 {
325            // Legal claim: $50K–$2M
326            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            // Restructuring: 1–3% of revenue
330            let pct: f64 = self.rng.random_range(0.01f64..=0.03);
331            (ProvisionType::Restructuring, rev_f * pct)
332        } else if roll < 0.85 {
333            // Environmental: $100K–$5M
334            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            // Onerous contract: 0.5–2% of revenue
338            let pct: f64 = self.rng.random_range(0.005f64..=0.02);
339            (ProvisionType::OnerousContract, rev_f * pct)
340        } else {
341            // Decommissioning: $200K–$10M (long-lived asset retirement)
342            let amount: f64 = self.rng.random_range(200_000.0f64..=10_000_000.0);
343            (ProvisionType::Decommissioning, amount)
344        };
345
346        // Probability of the outflow (drives recognition threshold check)
347        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    /// Generate contingent liability disclosures (not recognised on balance sheet).
366    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                // Contingent liabilities are always "Possible" for disclosure purposes
393                probability: ContingentProbability::Possible,
394                estimated_amount,
395                disclosure_only: true,
396                currency: currency.to_string(),
397            });
398        }
399        result
400    }
401}
402
403// ============================================================================
404// Journal entry builders
405// ============================================================================
406
407/// Build the provision recognition journal entry:
408///
409/// ```text
410/// DR  Provision Expense (6850)      recognition_amount
411///   CR  Provision Liability (2450)   recognition_amount
412/// ```
413fn 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    // Suppress unused import warning: INTEREST_EXPENSE used in unwinding JE below.
429    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/// Build the unwinding-of-discount journal entry:
448///
449/// ```text
450/// DR  Finance Cost / Interest Expense (7100)   unwinding_amount
451///   CR  Provision Liability (2450)               unwinding_amount
452/// ```
453#[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// ============================================================================
486// Decimal helpers
487// ============================================================================
488
489#[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}