use chrono::NaiveDate;
use datasynth_core::accounts::expense_accounts::INTEREST_EXPENSE;
use datasynth_core::accounts::provision_accounts;
use datasynth_core::models::journal_entry::{
JournalEntry, JournalEntryHeader, JournalEntryLine, TransactionSource,
};
use datasynth_core::models::provision::{
ContingentLiability, ContingentProbability, Provision, ProvisionMovement, ProvisionType,
};
use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
const IFRS_THRESHOLD: f64 = 0.50;
const US_GAAP_THRESHOLD: f64 = 0.75;
#[derive(Debug, Default)]
pub struct ProvisionSnapshot {
pub provisions: Vec<Provision>,
pub movements: Vec<ProvisionMovement>,
pub contingent_liabilities: Vec<ContingentLiability>,
pub journal_entries: Vec<JournalEntry>,
}
pub struct ProvisionGenerator {
uuid_factory: DeterministicUuidFactory,
rng: ChaCha8Rng,
}
impl ProvisionGenerator {
pub fn new(seed: u64) -> Self {
Self {
uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::Provision),
rng: ChaCha8Rng::seed_from_u64(seed),
}
}
pub fn generate(
&mut self,
entity_code: &str,
currency: &str,
revenue_proxy: Decimal,
reporting_date: NaiveDate,
period_label: &str,
framework: &str,
prior_opening: Option<Decimal>,
) -> ProvisionSnapshot {
let recognition_threshold = if framework == "IFRS" {
IFRS_THRESHOLD
} else {
US_GAAP_THRESHOLD
};
let provision_count = self.rng.random_range(3usize..=10);
let mut provisions: Vec<Provision> = Vec::with_capacity(provision_count);
let mut movements: Vec<ProvisionMovement> = Vec::with_capacity(provision_count);
let mut journal_entries: Vec<JournalEntry> = Vec::new();
for _ in 0..provision_count {
let (ptype, desc, prob, base_amount) =
self.sample_provision_type(revenue_proxy, reporting_date);
if prob <= recognition_threshold {
continue;
}
let best_estimate = round2(Decimal::try_from(base_amount).unwrap_or(dec!(10000)));
let range_low = round2(best_estimate * dec!(0.75));
let range_high = round2(best_estimate * dec!(1.50));
let months_to_settlement: i64 = self.rng.random_range(3i64..=60);
let is_long_term = months_to_settlement > 12;
let discount_rate = if is_long_term {
let rate_f: f64 = self.rng.random_range(0.03f64..=0.05);
Some(round6(Decimal::try_from(rate_f).unwrap_or(dec!(0.04))))
} else {
None
};
let utilization_date =
reporting_date + chrono::Months::new(months_to_settlement.unsigned_abs() as u32);
let prov_id = self.uuid_factory.next().to_string();
let provision = Provision {
id: prov_id.clone(),
entity_code: entity_code.to_string(),
provision_type: ptype,
description: desc.clone(),
best_estimate,
range_low,
range_high,
discount_rate,
expected_utilization_date: utilization_date,
framework: framework.to_string(),
currency: currency.to_string(),
};
let opening = Decimal::ZERO;
let additions = best_estimate;
let utilization_rate: f64 = self.rng.random_range(0.05f64..=0.15);
let utilizations =
round2(additions * Decimal::try_from(utilization_rate).unwrap_or(dec!(0.08)));
let reversal_rate: f64 = self.rng.random_range(0.0f64..=0.05);
let reversals =
round2(additions * Decimal::try_from(reversal_rate).unwrap_or(Decimal::ZERO));
let unwinding_of_discount =
if let (Some(prior_bal), Some(rate)) = (prior_opening, discount_rate) {
round2((prior_bal * rate).max(Decimal::ZERO))
} else {
Decimal::ZERO
};
let closing = (opening + additions - utilizations - reversals + unwinding_of_discount)
.max(Decimal::ZERO);
movements.push(ProvisionMovement {
provision_id: prov_id.clone(),
period: period_label.to_string(),
opening,
additions,
utilizations,
reversals,
unwinding_of_discount,
closing,
});
let recognition_amount = additions.max(Decimal::ZERO);
if recognition_amount > Decimal::ZERO {
let je = build_recognition_je(
&mut self.uuid_factory,
entity_code,
reporting_date,
recognition_amount,
&desc,
);
journal_entries.push(je);
}
provisions.push(provision);
}
let needed = 3usize.saturating_sub(provisions.len());
for i in 0..needed {
let base_amount = revenue_proxy * dec!(0.005); let best_estimate =
round2((base_amount + Decimal::from(i as u32 * 1000)).max(dec!(5000)));
let range_low = round2(best_estimate * dec!(0.75));
let range_high = round2(best_estimate * dec!(1.50));
let utilization_date =
reporting_date + chrono::Months::new(self.rng.random_range(6u32..=18));
let ptype = if i % 2 == 0 {
ProvisionType::Warranty
} else {
ProvisionType::LegalClaim
};
let desc = format!("{} provision — {} backfill", ptype, period_label);
let prov_id = self.uuid_factory.next().to_string();
let provision = Provision {
id: prov_id.clone(),
entity_code: entity_code.to_string(),
provision_type: ptype,
description: desc.clone(),
best_estimate,
range_low,
range_high,
discount_rate: None,
expected_utilization_date: utilization_date,
framework: framework.to_string(),
currency: currency.to_string(),
};
let opening = Decimal::ZERO;
let additions = best_estimate;
let utilizations = round2(additions * dec!(0.08));
let closing = (opening + additions - utilizations).max(Decimal::ZERO);
movements.push(ProvisionMovement {
provision_id: prov_id.clone(),
period: period_label.to_string(),
opening,
additions,
utilizations,
reversals: Decimal::ZERO,
unwinding_of_discount: Decimal::ZERO,
closing,
});
if additions > Decimal::ZERO {
let je = build_recognition_je(
&mut self.uuid_factory,
entity_code,
reporting_date,
additions,
&desc,
);
journal_entries.push(je);
}
provisions.push(provision);
}
let contingent_count = self.rng.random_range(1usize..=3);
let contingent_liabilities =
self.generate_contingent_liabilities(entity_code, currency, contingent_count);
ProvisionSnapshot {
provisions,
movements,
contingent_liabilities,
journal_entries,
}
}
fn sample_provision_type(
&mut self,
revenue_proxy: Decimal,
_reporting_date: NaiveDate,
) -> (ProvisionType, String, f64, f64) {
let roll: f64 = self.rng.random();
let rev_f: f64 = revenue_proxy.try_into().unwrap_or(1_000_000.0);
let (ptype, base_amount) = if roll < 0.35 {
let pct: f64 = self.rng.random_range(0.02f64..=0.05);
(ProvisionType::Warranty, rev_f * pct)
} else if roll < 0.60 {
let amount: f64 = self.rng.random_range(50_000.0f64..=2_000_000.0);
(ProvisionType::LegalClaim, amount)
} else if roll < 0.75 {
let pct: f64 = self.rng.random_range(0.01f64..=0.03);
(ProvisionType::Restructuring, rev_f * pct)
} else if roll < 0.85 {
let amount: f64 = self.rng.random_range(100_000.0f64..=5_000_000.0);
(ProvisionType::EnvironmentalRemediation, amount)
} else if roll < 0.95 {
let pct: f64 = self.rng.random_range(0.005f64..=0.02);
(ProvisionType::OnerousContract, rev_f * pct)
} else {
let amount: f64 = self.rng.random_range(200_000.0f64..=10_000_000.0);
(ProvisionType::Decommissioning, amount)
};
let probability: f64 = self.rng.random_range(0.51f64..=0.99);
let desc = match ptype {
ProvisionType::Warranty => "Product warranty — current sales cohort".to_string(),
ProvisionType::LegalClaim => "Pending litigation claim".to_string(),
ProvisionType::Restructuring => {
"Restructuring programme — redundancy costs".to_string()
}
ProvisionType::EnvironmentalRemediation => {
"Environmental site remediation obligation".to_string()
}
ProvisionType::OnerousContract => "Onerous lease / supply contract".to_string(),
ProvisionType::Decommissioning => "Asset retirement obligation (ARO)".to_string(),
};
(ptype, desc, probability, base_amount)
}
fn generate_contingent_liabilities(
&mut self,
entity_code: &str,
currency: &str,
count: usize,
) -> Vec<ContingentLiability> {
let natures = [
"Possible warranty claim from product recall investigation",
"Unresolved tax dispute with revenue authority",
"Environmental clean-up obligation under assessment",
"Patent infringement lawsuit — outcome uncertain",
"Customer class-action — settlement under negotiation",
"Supplier breach-of-contract claim",
];
let mut result = Vec::with_capacity(count);
for i in 0..count {
let nature = natures[i % natures.len()].to_string();
let amount_f: f64 = self.rng.random_range(25_000.0f64..=500_000.0);
let estimated_amount =
Some(round2(Decimal::try_from(amount_f).unwrap_or(dec!(100_000))));
result.push(ContingentLiability {
id: self.uuid_factory.next().to_string(),
entity_code: entity_code.to_string(),
nature,
probability: ContingentProbability::Possible,
estimated_amount,
disclosure_only: true,
currency: currency.to_string(),
});
}
result
}
}
fn build_recognition_je(
_uuid_factory: &mut DeterministicUuidFactory,
entity_code: &str,
posting_date: NaiveDate,
amount: Decimal,
description: &str,
) -> JournalEntry {
let mut header = JournalEntryHeader::new(entity_code.to_string(), posting_date);
header.header_text = Some(format!("Provision recognition — {description}"));
header.source = TransactionSource::Adjustment;
header.reference = Some("IAS37/ASC450-PROV".to_string());
let doc_id = header.document_id;
let mut je = JournalEntry::new(header);
je.add_line(JournalEntryLine::debit(
doc_id,
1,
provision_accounts::PROVISION_EXPENSE.to_string(),
amount,
));
je.add_line(JournalEntryLine::credit(
doc_id,
2,
provision_accounts::PROVISION_LIABILITY.to_string(),
amount,
));
je
}
#[allow(dead_code)]
fn build_unwinding_je(
_uuid_factory: &mut DeterministicUuidFactory,
entity_code: &str,
posting_date: NaiveDate,
amount: Decimal,
provision_description: &str,
) -> JournalEntry {
let mut header = JournalEntryHeader::new(entity_code.to_string(), posting_date);
header.header_text = Some(format!("Unwinding of discount — {provision_description}"));
header.source = TransactionSource::Adjustment;
header.reference = Some("IAS37-UNWIND".to_string());
let doc_id = header.document_id;
let mut je = JournalEntry::new(header);
je.add_line(JournalEntryLine::debit(
doc_id,
1,
INTEREST_EXPENSE.to_string(),
amount,
));
je.add_line(JournalEntryLine::credit(
doc_id,
2,
provision_accounts::PROVISION_LIABILITY.to_string(),
amount,
));
je
}
#[inline]
fn round2(d: Decimal) -> Decimal {
d.round_dp(2)
}
#[inline]
fn round6(d: Decimal) -> Decimal {
d.round_dp(6)
}