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    /// - `prior_opening`: opening balance of the provision from the prior period's closing
118    ///   balance.  When `Some`, the unwinding-of-discount is computed as
119    ///   `prior_opening × discount_rate × period_fraction` (IAS 37.60 / ASC 420).
120    ///   When `None` (first period or no carry-forward data), unwinding defaults to zero.
121    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        // ---- Step 1: determine provision count (3–10) -----------------------
138        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        // ---- Step 2: generate each provision --------------------------------
145        for _ in 0..provision_count {
146            let (ptype, desc, prob, base_amount) =
147                self.sample_provision_type(revenue_proxy, reporting_date);
148
149            // Framework-aware: only recognise if above threshold
150            if prob <= recognition_threshold {
151                // Below recognition threshold — will be collected as contingent
152                // liability below (if Possible, not Remote).
153                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            // Long-term provisions (> 12 months): apply discounting
161            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            // ---- Step 3: movement roll-forward (first-period run) -----------
189            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            // Unwinding of discount (IAS 37.60): discount_rate × opening balance × period_fraction.
198            // Uses `prior_opening` when provided (carry-forward scenario); defaults to zero for
199            // first-period runs where opening = 0 regardless.
200            let unwinding_of_discount =
201                if let (Some(prior_bal), Some(rate)) = (prior_opening, discount_rate) {
202                    // Assume each generation run covers one annual period (period_fraction = 1.0).
203                    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            // ---- Step 4: journal entries ------------------------------------
222            // Recognition JE: DR Provision Expense / CR Provision Liability
223            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        // Ensure we have at least 3 provisions even if probability sampling
239        // removed some items — backfill with warranty/legal if needed.
240        let needed = 3usize.saturating_sub(provisions.len());
241        for i in 0..needed {
242            let base_amount = revenue_proxy * dec!(0.005); // 0.5% of revenue
243            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        // ---- Step 5: contingent liabilities (1–3, disclosure only) ----------
303        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    // -------------------------------------------------------------------------
316    // Helpers
317    // -------------------------------------------------------------------------
318
319    /// Sample a provision type with associated description and probability.
320    ///
321    /// Returns `(ProvisionType, description, probability, base_amount_f64)`.
322    fn sample_provision_type(
323        &mut self,
324        revenue_proxy: Decimal,
325        _reporting_date: NaiveDate,
326    ) -> (ProvisionType, String, f64, f64) {
327        // Weighted selection: Warranty 35%, Legal 25%, Restructuring 15%,
328        // Environmental 10%, Onerous 10%, Decommissioning 5%.
329        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            // Warranty: 2–5% of revenue
334            let pct: f64 = self.rng.random_range(0.02f64..=0.05);
335            (ProvisionType::Warranty, rev_f * pct)
336        } else if roll < 0.60 {
337            // Legal claim: $50K–$2M
338            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            // Restructuring: 1–3% of revenue
342            let pct: f64 = self.rng.random_range(0.01f64..=0.03);
343            (ProvisionType::Restructuring, rev_f * pct)
344        } else if roll < 0.85 {
345            // Environmental: $100K–$5M
346            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            // Onerous contract: 0.5–2% of revenue
350            let pct: f64 = self.rng.random_range(0.005f64..=0.02);
351            (ProvisionType::OnerousContract, rev_f * pct)
352        } else {
353            // Decommissioning: $200K–$10M (long-lived asset retirement)
354            let amount: f64 = self.rng.random_range(200_000.0f64..=10_000_000.0);
355            (ProvisionType::Decommissioning, amount)
356        };
357
358        // Probability of the outflow (drives recognition threshold check)
359        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    /// Generate contingent liability disclosures (not recognised on balance sheet).
378    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                // Contingent liabilities are always "Possible" for disclosure purposes
405                probability: ContingentProbability::Possible,
406                estimated_amount,
407                disclosure_only: true,
408                currency: currency.to_string(),
409            });
410        }
411        result
412    }
413}
414
415// ============================================================================
416// Journal entry builders
417// ============================================================================
418
419/// Build the provision recognition journal entry:
420///
421/// ```text
422/// DR  Provision Expense (6850)      recognition_amount
423///   CR  Provision Liability (2450)   recognition_amount
424/// ```
425fn 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    // Suppress unused import warning: INTEREST_EXPENSE used in unwinding JE below.
441    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/// Build the unwinding-of-discount journal entry:
460///
461/// ```text
462/// DR  Finance Cost / Interest Expense (7100)   unwinding_amount
463///   CR  Provision Liability (2450)               unwinding_amount
464/// ```
465#[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// ============================================================================
498// Decimal helpers
499// ============================================================================
500
501#[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}