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::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
57// ============================================================================
58// IFRS recognition threshold (probability > 50%)
59// ============================================================================
60const IFRS_THRESHOLD: f64 = 0.50;
61/// US GAAP recognition threshold (probability > 75%)
62const US_GAAP_THRESHOLD: f64 = 0.75;
63
64// ============================================================================
65// Snapshot
66// ============================================================================
67
68/// All outputs from one provision generation run.
69#[derive(Debug, Default)]
70pub struct ProvisionSnapshot {
71    /// Recognised provisions (balance-sheet items).
72    pub provisions: Vec<Provision>,
73    /// Provision movement roll-forwards (one per provision).
74    pub movements: Vec<ProvisionMovement>,
75    /// Contingent liabilities (disclosed, not recognised).
76    pub contingent_liabilities: Vec<ContingentLiability>,
77    /// Journal entries (provision expense + unwinding of discount).
78    pub journal_entries: Vec<JournalEntry>,
79}
80
81// ============================================================================
82// Generator
83// ============================================================================
84
85/// Generates provisions and contingencies data for a reporting entity.
86pub struct ProvisionGenerator {
87    uuid_factory: DeterministicUuidFactory,
88    rng: ChaCha8Rng,
89}
90
91impl ProvisionGenerator {
92    /// Create a new generator with a deterministic seed.
93    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    /// Generate provisions and contingencies for one entity.
101    ///
102    /// # Parameters
103    /// - `entity_code`: company / entity identifier
104    /// - `currency`: reporting currency code (e.g. `"USD"`)
105    /// - `revenue_proxy`: approximate annual revenue used to size warranty provisions
106    /// - `reporting_date`: balance-sheet date (provisions dated to this period)
107    /// - `period_label`: label for the movement roll-forward (e.g. `"FY2024"`)
108    /// - `framework`: `"IFRS"` or `"US_GAAP"`
109    /// - `prior_opening`: opening balance of the provision from the prior period's closing
110    ///   balance.  When `Some`, the unwinding-of-discount is computed as
111    ///   `prior_opening × discount_rate × period_fraction` (IAS 37.60 / ASC 420).
112    ///   When `None` (first period or no carry-forward data), unwinding defaults to zero.
113    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        // ---- Step 1: determine provision count (3–10) -----------------------
130        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        // ---- Step 2: generate each provision --------------------------------
137        for _ in 0..provision_count {
138            let (ptype, desc, prob, base_amount) =
139                self.sample_provision_type(revenue_proxy, reporting_date);
140
141            // Framework-aware: only recognise if above threshold
142            if prob <= recognition_threshold {
143                // Below recognition threshold — will be collected as contingent
144                // liability below (if Possible, not Remote).
145                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            // Long-term provisions (> 12 months): apply discounting
153            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            // ---- Step 3: movement roll-forward (first-period run) -----------
181            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            // Unwinding of discount (IAS 37.60): discount_rate × opening balance × period_fraction.
190            // Uses `prior_opening` when provided (carry-forward scenario); defaults to zero for
191            // first-period runs where opening = 0 regardless.
192            let unwinding_of_discount =
193                if let (Some(prior_bal), Some(rate)) = (prior_opening, discount_rate) {
194                    // Assume each generation run covers one annual period (period_fraction = 1.0).
195                    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            // ---- Step 4: journal entries ------------------------------------
214            // Recognition JE: DR Provision Expense / CR Provision Liability
215            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        // Ensure we have at least 3 provisions even if probability sampling
231        // removed some items — backfill with warranty/legal if needed.
232        let needed = 3usize.saturating_sub(provisions.len());
233        for i in 0..needed {
234            let base_amount = revenue_proxy * dec!(0.005); // 0.5% of revenue
235            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        // ---- Step 5: contingent liabilities (1–3, disclosure only) ----------
295        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    // -------------------------------------------------------------------------
308    // Helpers
309    // -------------------------------------------------------------------------
310
311    /// Sample a provision type with associated description and probability.
312    ///
313    /// Returns `(ProvisionType, description, probability, base_amount_f64)`.
314    fn sample_provision_type(
315        &mut self,
316        revenue_proxy: Decimal,
317        _reporting_date: NaiveDate,
318    ) -> (ProvisionType, String, f64, f64) {
319        // Weighted selection: Warranty 35%, Legal 25%, Restructuring 15%,
320        // Environmental 10%, Onerous 10%, Decommissioning 5%.
321        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            // Warranty: 2–5% of revenue
326            let pct: f64 = self.rng.random_range(0.02f64..=0.05);
327            (ProvisionType::Warranty, rev_f * pct)
328        } else if roll < 0.60 {
329            // Legal claim: $50K–$2M
330            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            // Restructuring: 1–3% of revenue
334            let pct: f64 = self.rng.random_range(0.01f64..=0.03);
335            (ProvisionType::Restructuring, rev_f * pct)
336        } else if roll < 0.85 {
337            // Environmental: $100K–$5M
338            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            // Onerous contract: 0.5–2% of revenue
342            let pct: f64 = self.rng.random_range(0.005f64..=0.02);
343            (ProvisionType::OnerousContract, rev_f * pct)
344        } else {
345            // Decommissioning: $200K–$10M (long-lived asset retirement)
346            let amount: f64 = self.rng.random_range(200_000.0f64..=10_000_000.0);
347            (ProvisionType::Decommissioning, amount)
348        };
349
350        // Probability of the outflow (drives recognition threshold check)
351        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    /// Generate contingent liability disclosures (not recognised on balance sheet).
370    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                // Contingent liabilities are always "Possible" for disclosure purposes
397                probability: ContingentProbability::Possible,
398                estimated_amount,
399                disclosure_only: true,
400                currency: currency.to_string(),
401            });
402        }
403        result
404    }
405}
406
407// ============================================================================
408// Journal entry builders
409// ============================================================================
410
411/// Build the provision recognition journal entry:
412///
413/// ```text
414/// DR  Provision Expense (6850)      recognition_amount
415///   CR  Provision Liability (2450)   recognition_amount
416/// ```
417fn 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/// Build the unwinding-of-discount journal entry:
449///
450/// ```text
451/// DR  Finance Cost / Interest Expense (7100)   unwinding_amount
452///   CR  Provision Liability (2450)               unwinding_amount
453/// ```
454#[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// ============================================================================
487// Decimal helpers
488// ============================================================================
489
490#[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}